diff --git a/client/src/extension.ts b/client/src/extension.ts index bcb2586..ceb7e2d 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -3,9 +3,7 @@ import { LanguageClient } from 'vscode-languageclient/node' import * as jobs from './engine/jobs' -import ensureFlixExists from './util/ensureFlixExists' import createLanguageClient from './util/createLanguageClient' -import showStartupProgress from './util/showStartupProgress' import eventEmitter from './services/eventEmitter' import initialiseState from './services/state' @@ -15,7 +13,10 @@ import * as handlers from './handlers' import { callResolversAndEmptyList } from './services/timers' import { registerFlixReleaseDocumentProvider } from './services/releaseVirtualDocument' import { USER_MESSAGE } from './util/userMessages' -import { StatusCode } from './util/statusCodes' + +import { setupProjectWatchers, setupSingleFileTracking, disposeWatchers } from './lsp/fileWatchers' +import { startSession } from './lsp/session' +import { getUserConfiguration } from './lsp/notifications' export interface LaunchOptions { shouldUpdateFlix: boolean @@ -27,22 +28,9 @@ export const defaultLaunchOptions: LaunchOptions = { let client: LanguageClient -let hasReceivedReadyMessage = false - -let flixWatcher: vscode.FileSystemWatcher - -let pkgWatcher: vscode.FileSystemWatcher - -let jarWatcher: vscode.FileSystemWatcher - -let tomlWatcher: vscode.FileSystemWatcher - -let knownFlixFiles: Set = new Set() -let knownPkgFiles: Set = new Set() -let knownJarFiles: Set = new Set() -let reconcileTimer: ReturnType | undefined +let outputChannel: vscode.OutputChannel -const extensionObject = vscode.extensions.getExtension('flix.flix') +let flixLspTerminal: FlixLspTerminal /** * Whether the extension is running in project mode (a workspace folder is open) @@ -79,147 +67,12 @@ export function getFlixTomlGlobPattern() { return new vscode.RelativePattern(vscode.workspace.workspaceFolders![0], 'flix.toml') } -let outputChannel: vscode.OutputChannel - -let flixLspTerminal: FlixLspTerminal - -/** - * Convert URI to file scheme URI shared by e.g. TextDocument's URI. - * - * @param uri {vscode.Uri} - */ -function vsCodeUriToUriString(uri: vscode.Uri) { - return vscode.Uri.file(uri.path).toString(false) -} - -/** - * Re-scans the filesystem and diffs against known files. - * Sends add/rem notifications for any discrepancies. - * - * This handles folder deletion/creation where onDidDelete/onDidCreate - * fires for the folder but not for individual files inside it. - */ -async function reconcileFiles() { - if (!isProjectMode()) return - - const [currentFlix, currentPkgs, currentJars] = await Promise.all([ - vscode.workspace.findFiles(getFlixGlobPattern()).then(uris => new Set(uris.map(vsCodeUriToUriString))), - vscode.workspace.findFiles(getFpkgGlobPattern()).then(uris => new Set(uris.map(vsCodeUriToUriString))), - vscode.workspace.findFiles(getJarGlobPattern()).then(uris => new Set(uris.map(vsCodeUriToUriString))), - ]) - - for (const uri of knownFlixFiles) { - if (!currentFlix.has(uri)) { - client.sendNotification(jobs.Request.apiRemUri, { uri }) - } - } - for (const uri of currentFlix) { - if (!knownFlixFiles.has(uri)) { - client.sendNotification(jobs.Request.apiAddUri, { uri }) - } - } - - for (const uri of knownPkgFiles) { - if (!currentPkgs.has(uri)) { - client.sendNotification(jobs.Request.apiRemPkg, { uri }) - } - } - for (const uri of currentPkgs) { - if (!knownPkgFiles.has(uri)) { - client.sendNotification(jobs.Request.apiAddPkg, { uri }) - } - } - - for (const uri of knownJarFiles) { - if (!currentJars.has(uri)) { - client.sendNotification(jobs.Request.apiRemJar, { uri }) - } - } - for (const uri of currentJars) { - if (!knownJarFiles.has(uri)) { - client.sendNotification(jobs.Request.apiAddJar, { uri }) - } - } - - knownFlixFiles = currentFlix - knownPkgFiles = currentPkgs - knownJarFiles = currentJars -} - -function scheduleReconciliation() { - if (reconcileTimer !== undefined) { - clearTimeout(reconcileTimer) - } - reconcileTimer = setTimeout(() => { - reconcileTimer = undefined - reconcileFiles() - }, 300) -} - function makeHandleRestartClient(context: vscode.ExtensionContext, launchOptions?: LaunchOptions) { return async function handleRestartClient() { callResolversAndEmptyList() - await startSession(context, launchOptions, client) - } -} - -async function handleShowAst({ status, result }) { - if (status === StatusCode.Success) { - const content: string = 'ASTs saved to: ' + result.path - vscode.window.showInformationMessage(content) - } else { - const msg = USER_MESSAGE.CANT_SHOW_AST() - vscode.window.showInformationMessage(msg) - } -} - -function getUserConfiguration() { - return vscode.workspace.getConfiguration('flix') -} - -function stripAnsi(text: string): string { - // Matches ANSI escape sequences: ESC[ followed by parameters and a command letter - return text.replace(/\x1b\[[0-9;]*m/g, '') -} - -function handlePrintDiagnostics({ status, result }) { - if (getUserConfiguration().clearOutput.enabled) { - flixLspTerminal.clear() - } - - // Check if there are any errors - const hasErrors = result.some(res => res.diagnostics.some(diag => diag.severity <= 2)) - - if (!hasErrors) { - // Only print success message after ready message has been shown - // This avoids showing "Program compiles successfully" before "Flix Ready" - if (hasReceivedReadyMessage) { - flixLspTerminal.writeLine('\x1b[32m' + USER_MESSAGE.COMPILE_SUCCESS() + '\x1b[0m') - } - } else { - for (const res of result) { - for (const diag of res.diagnostics) { - if (diag.severity <= 2) { - flixLspTerminal.writeLine(diag.fullMessage) - } - } - } - } -} - -interface Action { - title: string - command: { - type: 'openFile' - path: string - } -} -async function handleError({ message, actions }: { message: string; actions: Action[] }) { - const selection = await vscode.window.showErrorMessage(message, ...actions.map(a => a.title)) - const action = actions.find(a => a.title === selection) - if (action?.command?.type === 'openFile') { - const uri = vscode.Uri.file(action.command.path) - vscode.window.showTextDocument(uri) + await startSession(context, launchOptions, client, outputChannel, flixLspTerminal, () => { + handlers.initSharedRepl(context, launchOptions) + }) } } @@ -282,78 +135,10 @@ export async function activate(context: vscode.ExtensionContext, launchOptions: if (isProjectMode()) { // In project mode, watch the file system for .flix/.fpkg/.jar/flix.toml changes. - - flixWatcher = vscode.workspace.createFileSystemWatcher(getFlixGlobPattern()) - flixWatcher.onDidDelete((vsCodeUri: vscode.Uri) => { - const uri = vsCodeUriToUriString(vsCodeUri) - knownFlixFiles.delete(uri) - client.sendNotification(jobs.Request.apiRemUri, { uri }) - scheduleReconciliation() - }) - flixWatcher.onDidCreate((vsCodeUri: vscode.Uri) => { - const uri = vsCodeUriToUriString(vsCodeUri) - knownFlixFiles.add(uri) - client.sendNotification(jobs.Request.apiAddUri, { uri }) - scheduleReconciliation() - }) - - pkgWatcher = vscode.workspace.createFileSystemWatcher(getFpkgGlobPattern()) - pkgWatcher.onDidDelete((vsCodeUri: vscode.Uri) => { - const uri = vsCodeUriToUriString(vsCodeUri) - knownPkgFiles.delete(uri) - client.sendNotification(jobs.Request.apiRemPkg, { uri }) - scheduleReconciliation() - }) - pkgWatcher.onDidCreate((vsCodeUri: vscode.Uri) => { - const uri = vsCodeUriToUriString(vsCodeUri) - knownPkgFiles.add(uri) - client.sendNotification(jobs.Request.apiAddPkg, { uri }) - scheduleReconciliation() - }) - - jarWatcher = vscode.workspace.createFileSystemWatcher(getJarGlobPattern()) - jarWatcher.onDidDelete((vsCodeUri: vscode.Uri) => { - const uri = vsCodeUriToUriString(vsCodeUri) - knownJarFiles.delete(uri) - client.sendNotification(jobs.Request.apiRemJar, { uri }) - scheduleReconciliation() - }) - jarWatcher.onDidCreate((vsCodeUri: vscode.Uri) => { - const uri = vsCodeUriToUriString(vsCodeUri) - knownJarFiles.add(uri) - client.sendNotification(jobs.Request.apiAddJar, { uri }) - scheduleReconciliation() - }) - - tomlWatcher = vscode.workspace.createFileSystemWatcher(getFlixTomlGlobPattern()) - tomlWatcher.onDidChange(() => { - const { msg, option1, option2 } = USER_MESSAGE.ASK_RELOAD_TOML() - const doReload = vscode.window.showInformationMessage(msg, option1, option2) - doReload.then(res => { - if (res === 'Yes') { - makeHandleRestartClient(context, launchOptions)() - } - }) - }) - - // Watch for folder-level deletions/creations (e.g. deleting src/) that - // the file-specific watchers above don't catch. - vscode.workspace.onDidDeleteFiles(() => scheduleReconciliation()) - vscode.workspace.onDidCreateFiles(() => scheduleReconciliation()) + setupProjectWatchers(client, makeHandleRestartClient(context, launchOptions)) } else { // In single-file mode there is no workspace folder to watch. - // Instead, track document open/close to add/remove .flix files from the compiler. - // Content changes are already handled by the LSP TextDocumentSync mechanism. - vscode.workspace.onDidOpenTextDocument(doc => { - if (doc.uri.path.endsWith('.flix')) { - client.sendNotification(jobs.Request.apiAddUri, { uri: vsCodeUriToUriString(doc.uri) }) - } - }) - vscode.workspace.onDidCloseTextDocument(doc => { - if (doc.uri.path.endsWith('.flix')) { - client.sendNotification(jobs.Request.apiRemUri, { uri: vsCodeUriToUriString(doc.uri) }) - } - }) + setupSingleFileTracking(client) } vscode.window.onDidChangeActiveTextEditor(handlers.handleChangeEditor) @@ -361,123 +146,14 @@ export async function activate(context: vscode.ExtensionContext, launchOptions: client.sendNotification(jobs.Request.internalReplaceConfiguration, getUserConfiguration()) }) - await startSession(context, launchOptions, client) -} - -async function startSession( - context: vscode.ExtensionContext, - launchOptions: LaunchOptions = defaultLaunchOptions, - client: LanguageClient, -) { - // Reset ready message flag for new session - hasReceivedReadyMessage = false - - // clear listeners from previous sessions - eventEmitter.removeAllListeners() - - // clear outputs - outputChannel.clear() - - const globalStoragePath = context.globalStorageUri.fsPath - const workspaceFolders = vscode.workspace.workspaceFolders?.map(ws => ws.uri.fsPath) - - // In project mode, discover files via workspace glob patterns. - // In single-file mode, use the currently open .flix documents instead. - let workspaceFiles: string[] - let workspacePkgs: string[] - let workspaceJars: string[] - if (isProjectMode()) { - workspaceFiles = (await vscode.workspace.findFiles(getFlixGlobPattern())).map(vsCodeUriToUriString) - workspacePkgs = (await vscode.workspace.findFiles(getFpkgGlobPattern())).map(vsCodeUriToUriString) - workspaceJars = (await vscode.workspace.findFiles(getJarGlobPattern())).map(vsCodeUriToUriString) - knownFlixFiles = new Set(workspaceFiles) - knownPkgFiles = new Set(workspacePkgs) - knownJarFiles = new Set(workspaceJars) - } else { - workspaceFiles = vscode.workspace.textDocuments - .filter(doc => doc.uri.path.endsWith('.flix')) - .map(doc => vsCodeUriToUriString(doc.uri)) - workspacePkgs = [] - workspaceJars = [] - knownFlixFiles = new Set(workspaceFiles) - knownPkgFiles = new Set() - knownJarFiles = new Set() - } - - // Wait until we're sure flix exists - const flixFilename = await ensureFlixExists({ - globalStoragePath, - workspaceFolders, - shouldUpdateFlix: launchOptions.shouldUpdateFlix, - }) - - // A staged update was downloaded — a reload prompt is already showing. - // Skip engine startup so the user doesn't see a stale "Starting Flix" progress. - if (!flixFilename) { - return - } - - // Show a startup progress that times out after 10 (default) seconds - showStartupProgress() - - // Send start notification to the server which actually starts the Flix compiler - client.sendNotification(jobs.Request.internalReady, { - flixFilename, - workspaceFolders, - extensionPath: extensionObject.extensionPath || context.extensionPath, - extensionVersion: extensionObject.packageJSON.version, - globalStoragePath, - workspaceFiles, - workspacePkgs, - workspaceJars, - userConfiguration: getUserConfiguration(), - }) - - // Handle when server has answered back after getting the notification above - client.onNotification(jobs.Request.internalReady, () => { - // waits for server to answer back after having started successfully - eventEmitter.emit(jobs.Request.internalReady) - + await startSession(context, launchOptions, client, outputChannel, flixLspTerminal, () => { // start the Flix runner (but only after the Flix LSP instance has started.) handlers.initSharedRepl(context, launchOptions) }) - - client.onNotification(jobs.Request.internalFinishedJob, () => { - // only one job runs at once, so currently not trying to distinguish - eventEmitter.emit(jobs.Request.internalFinishedJob) - }) - - client.onNotification(jobs.Request.internalFinishedAllJobs, () => - eventEmitter.emit(jobs.Request.internalFinishedAllJobs), - ) - - client.onNotification(jobs.Request.internalDiagnostics, handlePrintDiagnostics) - - client.onNotification(jobs.Request.internalMessage, (message: string) => { - hasReceivedReadyMessage = true - flixLspTerminal.writeLine('\x1b[34m' + message + '\x1b[0m') - vscode.window.showInformationMessage(message) - }) - - client.onNotification(jobs.Request.internalRecompiling, () => { - if (hasReceivedReadyMessage) { - flixLspTerminal.writeLine('\x1b[38;5;172m' + USER_MESSAGE.RECOMPILING() + '\x1b[0m') - } - }) - - client.onNotification(jobs.Request.internalError, handleError) - - client.onNotification(jobs.Request.lspShowAst, handleShowAst) } export function deactivate(): Thenable | undefined { - flixWatcher && flixWatcher.dispose() - pkgWatcher && pkgWatcher.dispose() - jarWatcher && jarWatcher.dispose() - tomlWatcher && tomlWatcher.dispose() + disposeWatchers() outputChannel && outputChannel.dispose() - if (reconcileTimer !== undefined) { - clearTimeout(reconcileTimer) - } return client ? client.stop() : undefined } diff --git a/client/src/lsp/fileWatchers.ts b/client/src/lsp/fileWatchers.ts new file mode 100644 index 0000000..8621702 --- /dev/null +++ b/client/src/lsp/fileWatchers.ts @@ -0,0 +1,218 @@ +import * as vscode from 'vscode' +import { LanguageClient } from 'vscode-languageclient/node' +import * as jobs from '../engine/jobs' +import { + isProjectMode, + getFlixGlobPattern, + getFpkgGlobPattern, + getJarGlobPattern, + getFlixTomlGlobPattern, +} from '../extension' +import { USER_MESSAGE } from '../util/userMessages' + +let flixWatcher: vscode.FileSystemWatcher +let pkgWatcher: vscode.FileSystemWatcher +let jarWatcher: vscode.FileSystemWatcher +let tomlWatcher: vscode.FileSystemWatcher + +let knownFlixFiles: Set = new Set() +let knownPkgFiles: Set = new Set() +let knownJarFiles: Set = new Set() +let reconcileTimer: ReturnType | undefined + +/** + * Convert URI to file scheme URI shared by e.g. TextDocument's URI. + * + * @param uri {vscode.Uri} + */ +export function vsCodeUriToUriString(uri: vscode.Uri) { + return vscode.Uri.file(uri.path).toString(false) +} + +/** + * Re-scans the filesystem and diffs against known files. + * Sends add/rem notifications for any discrepancies. + * + * This handles folder deletion/creation where onDidDelete/onDidCreate + * fires for the folder but not for individual files inside it. + */ +async function reconcileFiles(client: LanguageClient) { + if (!isProjectMode()) return + + const [currentFlix, currentPkgs, currentJars] = await Promise.all([ + vscode.workspace.findFiles(getFlixGlobPattern()).then(uris => new Set(uris.map(vsCodeUriToUriString))), + vscode.workspace.findFiles(getFpkgGlobPattern()).then(uris => new Set(uris.map(vsCodeUriToUriString))), + vscode.workspace.findFiles(getJarGlobPattern()).then(uris => new Set(uris.map(vsCodeUriToUriString))), + ]) + + for (const uri of knownFlixFiles) { + if (!currentFlix.has(uri)) { + client.sendNotification(jobs.Request.apiRemUri, { uri }) + } + } + for (const uri of currentFlix) { + if (!knownFlixFiles.has(uri)) { + client.sendNotification(jobs.Request.apiAddUri, { uri }) + } + } + + for (const uri of knownPkgFiles) { + if (!currentPkgs.has(uri)) { + client.sendNotification(jobs.Request.apiRemPkg, { uri }) + } + } + for (const uri of currentPkgs) { + if (!knownPkgFiles.has(uri)) { + client.sendNotification(jobs.Request.apiAddPkg, { uri }) + } + } + + for (const uri of knownJarFiles) { + if (!currentJars.has(uri)) { + client.sendNotification(jobs.Request.apiRemJar, { uri }) + } + } + for (const uri of currentJars) { + if (!knownJarFiles.has(uri)) { + client.sendNotification(jobs.Request.apiAddJar, { uri }) + } + } + + knownFlixFiles = currentFlix + knownPkgFiles = currentPkgs + knownJarFiles = currentJars +} + +function scheduleReconciliation(client: LanguageClient) { + if (reconcileTimer !== undefined) { + clearTimeout(reconcileTimer) + } + reconcileTimer = setTimeout(() => { + reconcileTimer = undefined + reconcileFiles(client) + }, 300) +} + +/** + * Set up file system watchers for project mode. + * Watches .flix, .fpkg, .jar, and flix.toml files. + */ +export function setupProjectWatchers(client: LanguageClient, onRestartClient: () => void) { + flixWatcher = vscode.workspace.createFileSystemWatcher(getFlixGlobPattern()) + flixWatcher.onDidDelete((vsCodeUri: vscode.Uri) => { + const uri = vsCodeUriToUriString(vsCodeUri) + knownFlixFiles.delete(uri) + client.sendNotification(jobs.Request.apiRemUri, { uri }) + scheduleReconciliation(client) + }) + flixWatcher.onDidCreate((vsCodeUri: vscode.Uri) => { + const uri = vsCodeUriToUriString(vsCodeUri) + knownFlixFiles.add(uri) + client.sendNotification(jobs.Request.apiAddUri, { uri }) + scheduleReconciliation(client) + }) + + pkgWatcher = vscode.workspace.createFileSystemWatcher(getFpkgGlobPattern()) + pkgWatcher.onDidDelete((vsCodeUri: vscode.Uri) => { + const uri = vsCodeUriToUriString(vsCodeUri) + knownPkgFiles.delete(uri) + client.sendNotification(jobs.Request.apiRemPkg, { uri }) + scheduleReconciliation(client) + }) + pkgWatcher.onDidCreate((vsCodeUri: vscode.Uri) => { + const uri = vsCodeUriToUriString(vsCodeUri) + knownPkgFiles.add(uri) + client.sendNotification(jobs.Request.apiAddPkg, { uri }) + scheduleReconciliation(client) + }) + + jarWatcher = vscode.workspace.createFileSystemWatcher(getJarGlobPattern()) + jarWatcher.onDidDelete((vsCodeUri: vscode.Uri) => { + const uri = vsCodeUriToUriString(vsCodeUri) + knownJarFiles.delete(uri) + client.sendNotification(jobs.Request.apiRemJar, { uri }) + scheduleReconciliation(client) + }) + jarWatcher.onDidCreate((vsCodeUri: vscode.Uri) => { + const uri = vsCodeUriToUriString(vsCodeUri) + knownJarFiles.add(uri) + client.sendNotification(jobs.Request.apiAddJar, { uri }) + scheduleReconciliation(client) + }) + + tomlWatcher = vscode.workspace.createFileSystemWatcher(getFlixTomlGlobPattern()) + tomlWatcher.onDidChange(() => { + const { msg, option1, option2 } = USER_MESSAGE.ASK_RELOAD_TOML() + const doReload = vscode.window.showInformationMessage(msg, option1, option2) + doReload.then(res => { + if (res === 'Yes') { + onRestartClient() + } + }) + }) + + // Watch for folder-level deletions/creations (e.g. deleting src/) that + // the file-specific watchers above don't catch. + vscode.workspace.onDidDeleteFiles(() => scheduleReconciliation(client)) + vscode.workspace.onDidCreateFiles(() => scheduleReconciliation(client)) +} + +/** + * Set up document tracking for single-file mode. + * Tracks document open/close to add/remove .flix files from the compiler. + * Content changes are already handled by the LSP TextDocumentSync mechanism. + */ +export function setupSingleFileTracking(client: LanguageClient) { + vscode.workspace.onDidOpenTextDocument(doc => { + if (doc.uri.path.endsWith('.flix')) { + client.sendNotification(jobs.Request.apiAddUri, { uri: vsCodeUriToUriString(doc.uri) }) + } + }) + vscode.workspace.onDidCloseTextDocument(doc => { + if (doc.uri.path.endsWith('.flix')) { + client.sendNotification(jobs.Request.apiRemUri, { uri: vsCodeUriToUriString(doc.uri) }) + } + }) +} + +/** + * Discover all workspace files and update the known file sets. + * In project mode, uses workspace glob patterns. + * In single-file mode, uses currently open .flix documents. + */ +export async function discoverWorkspaceFiles(): Promise<{ + workspaceFiles: string[] + workspacePkgs: string[] + workspaceJars: string[] +}> { + if (isProjectMode()) { + const workspaceFiles = (await vscode.workspace.findFiles(getFlixGlobPattern())).map(vsCodeUriToUriString) + const workspacePkgs = (await vscode.workspace.findFiles(getFpkgGlobPattern())).map(vsCodeUriToUriString) + const workspaceJars = (await vscode.workspace.findFiles(getJarGlobPattern())).map(vsCodeUriToUriString) + knownFlixFiles = new Set(workspaceFiles) + knownPkgFiles = new Set(workspacePkgs) + knownJarFiles = new Set(workspaceJars) + return { workspaceFiles, workspacePkgs, workspaceJars } + } else { + const workspaceFiles = vscode.workspace.textDocuments + .filter(doc => doc.uri.path.endsWith('.flix')) + .map(doc => vsCodeUriToUriString(doc.uri)) + knownFlixFiles = new Set(workspaceFiles) + knownPkgFiles = new Set() + knownJarFiles = new Set() + return { workspaceFiles, workspacePkgs: [], workspaceJars: [] } + } +} + +/** + * Dispose all file system watchers and clear the reconciliation timer. + */ +export function disposeWatchers() { + flixWatcher && flixWatcher.dispose() + pkgWatcher && pkgWatcher.dispose() + jarWatcher && jarWatcher.dispose() + tomlWatcher && tomlWatcher.dispose() + if (reconcileTimer !== undefined) { + clearTimeout(reconcileTimer) + } +} diff --git a/client/src/lsp/notifications.ts b/client/src/lsp/notifications.ts new file mode 100644 index 0000000..3982d9c --- /dev/null +++ b/client/src/lsp/notifications.ts @@ -0,0 +1,110 @@ +import * as vscode from 'vscode' +import { LanguageClient } from 'vscode-languageclient/node' +import * as jobs from '../engine/jobs' +import eventEmitter from '../services/eventEmitter' +import { FlixLspTerminal } from '../services/flixLspTerminal' +import { StatusCode } from '../util/statusCodes' +import { USER_MESSAGE } from '../util/userMessages' + +let hasReceivedReadyMessage = false + +export function getUserConfiguration() { + return vscode.workspace.getConfiguration('flix') +} + +function handleShowAst({ status, result }) { + if (status === StatusCode.Success) { + const content: string = 'ASTs saved to: ' + result.path + vscode.window.showInformationMessage(content) + } else { + const msg = USER_MESSAGE.CANT_SHOW_AST() + vscode.window.showInformationMessage(msg) + } +} + +interface Action { + title: string + command: { + type: 'openFile' + path: string + } +} + +async function handleError({ message, actions }: { message: string; actions: Action[] }) { + const selection = await vscode.window.showErrorMessage(message, ...actions.map(a => a.title)) + const action = actions.find(a => a.title === selection) + if (action?.command?.type === 'openFile') { + const uri = vscode.Uri.file(action.command.path) + vscode.window.showTextDocument(uri) + } +} + +/** + * Register all server-to-client notification listeners on the language client. + * Called at the start of each session. + * + * @param onReady Callback invoked when the server signals it is ready. + */ +export function setupNotificationListeners( + client: LanguageClient, + flixLspTerminal: FlixLspTerminal, + onReady: () => void, +) { + hasReceivedReadyMessage = false + + client.onNotification(jobs.Request.internalReady, () => { + // waits for server to answer back after having started successfully + eventEmitter.emit(jobs.Request.internalReady) + onReady() + }) + + client.onNotification(jobs.Request.internalFinishedJob, () => { + // only one job runs at once, so currently not trying to distinguish + eventEmitter.emit(jobs.Request.internalFinishedJob) + }) + + client.onNotification(jobs.Request.internalFinishedAllJobs, () => + eventEmitter.emit(jobs.Request.internalFinishedAllJobs), + ) + + client.onNotification(jobs.Request.internalDiagnostics, ({ status, result }) => { + if (getUserConfiguration().clearOutput.enabled) { + flixLspTerminal.clear() + } + + // Check if there are any errors + const hasErrors = result.some(res => res.diagnostics.some(diag => diag.severity <= 2)) + + if (!hasErrors) { + // Only print success message after ready message has been shown + // This avoids showing "Program compiles successfully" before "Flix Ready" + if (hasReceivedReadyMessage) { + flixLspTerminal.writeLine('\x1b[32m' + USER_MESSAGE.COMPILE_SUCCESS() + '\x1b[0m') + } + } else { + for (const res of result) { + for (const diag of res.diagnostics) { + if (diag.severity <= 2) { + flixLspTerminal.writeLine(diag.fullMessage) + } + } + } + } + }) + + client.onNotification(jobs.Request.internalMessage, (message: string) => { + hasReceivedReadyMessage = true + flixLspTerminal.writeLine('\x1b[34m' + message + '\x1b[0m') + vscode.window.showInformationMessage(message) + }) + + client.onNotification(jobs.Request.internalRecompiling, () => { + if (hasReceivedReadyMessage) { + flixLspTerminal.writeLine('\x1b[38;5;172m' + USER_MESSAGE.RECOMPILING() + '\x1b[0m') + } + }) + + client.onNotification(jobs.Request.internalError, handleError) + + client.onNotification(jobs.Request.lspShowAst, handleShowAst) +} diff --git a/client/src/lsp/session.ts b/client/src/lsp/session.ts new file mode 100644 index 0000000..f09c1ce --- /dev/null +++ b/client/src/lsp/session.ts @@ -0,0 +1,66 @@ +import * as vscode from 'vscode' +import { LanguageClient } from 'vscode-languageclient/node' + +import * as jobs from '../engine/jobs' +import ensureFlixExists from '../util/ensureFlixExists' +import showStartupProgress from '../util/showStartupProgress' +import eventEmitter from '../services/eventEmitter' +import { FlixLspTerminal } from '../services/flixLspTerminal' +import { discoverWorkspaceFiles } from './fileWatchers' +import { setupNotificationListeners, getUserConfiguration } from './notifications' + +const extensionObject = vscode.extensions.getExtension('flix.flix') + +export async function startSession( + context: vscode.ExtensionContext, + launchOptions: { shouldUpdateFlix?: boolean }, + client: LanguageClient, + outputChannel: vscode.OutputChannel, + flixLspTerminal: FlixLspTerminal, + onReady: () => void, +) { + // clear listeners from previous sessions + eventEmitter.removeAllListeners() + + // clear outputs + outputChannel.clear() + + const globalStoragePath = context.globalStorageUri.fsPath + const workspaceFolders = vscode.workspace.workspaceFolders?.map(ws => ws.uri.fsPath) + + // In project mode, discover files via workspace glob patterns. + // In single-file mode, use the currently open .flix documents instead. + const { workspaceFiles, workspacePkgs, workspaceJars } = await discoverWorkspaceFiles() + + // Wait until we're sure flix exists + const flixFilename = await ensureFlixExists({ + globalStoragePath, + workspaceFolders, + shouldUpdateFlix: launchOptions.shouldUpdateFlix, + }) + + // A staged update was downloaded — a reload prompt is already showing. + // Skip engine startup so the user doesn't see a stale "Starting Flix" progress. + if (!flixFilename) { + return + } + + // Show a startup progress that times out after 10 (default) seconds + showStartupProgress() + + // Send start notification to the server which actually starts the Flix compiler + client.sendNotification(jobs.Request.internalReady, { + flixFilename, + workspaceFolders, + extensionPath: extensionObject.extensionPath || context.extensionPath, + extensionVersion: extensionObject.packageJSON.version, + globalStoragePath, + workspaceFiles, + workspacePkgs, + workspaceJars, + userConfiguration: getUserConfiguration(), + }) + + // Handle when server has answered back after getting the notification above + setupNotificationListeners(client, flixLspTerminal, onReady) +}