diff --git a/client/src/extension.ts b/client/src/extension.ts index 1b4e56d..b549254 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -1,5 +1,5 @@ import * as path from 'path'; -import { ExtensionContext, workspace } from 'vscode'; +import type { ExtensionContext } from 'vscode'; import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from 'vscode-languageclient/node'; @@ -30,10 +30,6 @@ export function activate(context: ExtensionContext) { const clientOptions: LanguageClientOptions = { // Register the server for aqua source files documentSelector: [{ pattern: '**/*.aqua' }], - synchronize: { - // Notify the server about file changes to '.clientrc files contained in the workspace - fileEvents: workspace.createFileSystemWatcher('**/.clientrc'), - }, }; // Create the language client and start the client. diff --git a/package.json b/package.json index 15bdf13..4871965 100644 --- a/package.json +++ b/package.json @@ -62,19 +62,23 @@ "type": "object", "title": "Aqua", "properties": { - "aquaSettings": { - "imports": { - "scope": "resource", - "type": "array", - "default": [], - "description": "Adds imports for aqua file or project" - }, - "enableLegacyAutoImportSearch": { - "scope": "resource", - "type": "boolean", - "default": false, - "description": "Do not look for extra imports" - } + "aquaSettings.imports": { + "scope": "resource", + "type": "array", + "default": [], + "description": "Adds imports for aqua file or project" + }, + "aquaSettings.enableLegacyAutoImportSearch": { + "scope": "resource", + "type": "boolean", + "default": false, + "description": "Do not look for extra imports" + }, + "aquaSettings.fluencePath": { + "scope": "resource", + "type": ["string", "null"], + "default": null, + "description": "Path to fluence CLI executable" } } } diff --git a/server/src/cli.ts b/server/src/cli.ts new file mode 100644 index 0000000..3584c2e --- /dev/null +++ b/server/src/cli.ts @@ -0,0 +1,45 @@ +import { exec } from 'child_process'; + +export class FluenceCli { + readonly cliPath: string; + + /** + * @param cliPath Path to `fluence` executable. + * Defaults to `fluence` (i.e. it should be in PATH). + */ + constructor(cliPath?: string) { + this.cliPath = cliPath || 'fluence'; + } + + /** + * Returns output of `fluence aqua imports` + */ + async imports(): Promise { + const result = await this.runJson(['aqua', 'imports']); + if (Array.isArray(result) && result.every((i) => typeof i === 'string')) { + return result; + } else { + throw new Error(`Invalid result: ${JSON.stringify(result)}`); + } + } + + /** + * Runs `fluence` with given arguments and returns its stdout as JSON. + */ + private async runJson(args: string[]): Promise { + const cmd = `${this.cliPath} ${args.join(' ')}`; + return new Promise((resolve, reject) => { + exec(cmd, (err, stdout, _) => { + if (err) { + reject(err); + } else + try { + const result = JSON.parse(stdout); + resolve(result); + } catch (e) { + reject(e); + } + }); + }); + } +} diff --git a/server/src/server.ts b/server/src/server.ts index 28ec05a..fc38e21 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,5 +1,7 @@ import { createConnection, + DiagnosticSeverity, + DidChangeConfigurationNotification, InitializeParams, InitializeResult, ProposedFeatures, @@ -13,6 +15,8 @@ import type { DefinitionParams, Location } from 'vscode-languageserver'; import type { TokenLink } from '@fluencelabs/aqua-language-server-api/aqua-lsp-api'; import { compileAqua } from './validation'; +import { FluenceCli } from './cli'; +import { Settings, SettingsManager } from './settings'; // Create a connection to the server, using Node's IPC as a transport. // Also include all preview / proposed LSP features. @@ -25,11 +29,14 @@ let hasConfigurationCapability = false; let hasWorkspaceFolderCapability = false; let folders: WorkspaceFolder[] = []; -export interface Settings { - imports: string[]; - enableLegacyAutoImportSearch: boolean; +function createSettingsManager(cliPath?: string, defaultSettings?: Settings): SettingsManager { + const cli = new FluenceCli(cliPath); + const configuration = hasConfigurationCapability ? connection.workspace : undefined; + return new SettingsManager(cli, configuration, defaultSettings); } +let documentSettings = createSettingsManager(); + function searchDefinition(position: Position, name: string, locations: TokenLink[]): TokenLink | undefined { return locations.find( (token) => @@ -41,13 +48,6 @@ function searchDefinition(position: Position, name: string, locations: TokenLink ); } -// The global settings, used when the `workspace/configuration` request is not supported by the client. -const defaultSettings: Settings = { imports: [], enableLegacyAutoImportSearch: false }; -let globalSettings: Settings = defaultSettings; - -// Cache the settings of all open documents -const documentSettings: Map = new Map(); - // Cache all locations of all open documents const allLocations: Map = new Map(); @@ -89,37 +89,17 @@ async function onDefinition({ textDocument, position }: DefinitionParams): Promi connection.onDefinition(onDefinition); connection.onDidChangeConfiguration((change) => { - connection.console.log(change.settings); + connection.console.log(`onDidChangeConfiguration event ${JSON.stringify(change)}`); - globalSettings = (change.settings.aquaSettings || defaultSettings); + documentSettings = createSettingsManager(change.settings.aquaSettings.fluencePath, change.settings.aquaSettings); // Revalidate all open text documents documents.all().forEach(validateDocument); }); -async function getDocumentSettings(resource: string): Promise { - if (!hasConfigurationCapability) { - return Promise.resolve(globalSettings); - } - - let result = await documentSettings.get(resource); - if (!result) { - result = await connection.workspace.getConfiguration({ - scopeUri: resource, - section: 'aquaSettings', - }); - if (!result) { - result = defaultSettings; - } - documentSettings.set(resource, result); - } - - return result; -} - // Only keep settings for open documents documents.onDidClose((e) => { - documentSettings.delete(e.document.uri); + documentSettings.removeDocumentSettings(e.document.uri); }); connection.onInitialize((params: InitializeParams) => { @@ -127,7 +107,6 @@ connection.onInitialize((params: InitializeParams) => { const capabilities = params.capabilities; hasConfigurationCapability = !!(capabilities.workspace && !!capabilities.workspace.configuration); - hasWorkspaceFolderCapability = !!(capabilities.workspace && !!capabilities.workspace.workspaceFolders); if (params.workspaceFolders) { @@ -140,6 +119,7 @@ connection.onInitialize((params: InitializeParams) => { definitionProvider: true, }, }; + if (hasWorkspaceFolderCapability) { result.capabilities.workspace = { workspaceFolders: { @@ -147,15 +127,25 @@ connection.onInitialize((params: InitializeParams) => { }, }; } + return result; }); -connection.onInitialized(() => { +connection.onInitialized(async () => { connection.console.log('onInitialized event'); - connection.workspace.onDidChangeWorkspaceFolders((event) => { - folders = folders.concat(event.added); - folders = folders.filter((f) => !event.removed.includes(f)); - }); + + if (hasConfigurationCapability) { + connection.client.register(DidChangeConfigurationNotification.type, { + section: 'aquaSettings', + }); + } + + if (hasWorkspaceFolderCapability) { + connection.workspace.onDidChangeWorkspaceFolders((event) => { + folders = folders.concat(event.added); + folders = folders.filter((f) => !event.removed.includes(f)); + }); + } }); documents.onDidSave(async (change) => { @@ -169,7 +159,9 @@ documents.onDidOpen(async (change) => { }); async function validateDocument(textDocument: TextDocument): Promise { - const settings = await getDocumentSettings(textDocument.uri); + const settings = await documentSettings.getDocumentSettings(textDocument.uri); + + connection.console.log(`validateDocument ${textDocument.uri} with settings ${JSON.stringify(settings)}`); const [diagnostics, locations] = await compileAqua(settings, textDocument, folders, connection.console); @@ -177,6 +169,11 @@ async function validateDocument(textDocument: TextDocument): Promise { // Send the computed diagnostics to VSCode. connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }); + + // Request additional imports update if there are errors + if (diagnostics.some((d) => d.severity === DiagnosticSeverity.Error)) { + documentSettings.requestImportsUpdate(); + } } // Make the text document manager listen on the connection diff --git a/server/src/settings.ts b/server/src/settings.ts new file mode 100644 index 0000000..e201051 --- /dev/null +++ b/server/src/settings.ts @@ -0,0 +1,107 @@ +import type { Configuration } from 'vscode-languageserver/lib/common/configuration'; + +import type { FluenceCli } from './cli'; + +export interface Settings { + imports: string[]; + enableLegacyAutoImportSearch: boolean; +} + +function addImports(settings: Settings, imports?: string[]): Settings { + return { + ...settings, + imports: [...settings.imports, ...(imports ?? [])], + }; +} + +/** + * Compilation settings manager for documents + */ +export class SettingsManager { + private readonly defaultSettings: Settings = { + imports: [], + enableLegacyAutoImportSearch: false, + }; + private globalSettings: Settings = this.defaultSettings; + private documentSettings: Map = new Map(); + + private additionalImports: string[] = []; + private additionalImportsLastUpdated = 0; + private additionalImportsUpdateRequested = true; + + private readonly cli: FluenceCli; + private readonly configuration: Configuration | undefined; + + constructor(cli: FluenceCli, configuration?: Configuration, defaultSettings?: Settings) { + this.cli = cli; + this.configuration = configuration; + if (defaultSettings) { + this.defaultSettings = defaultSettings; + } + } + + /** + * Get settings for document or global settings if document settings are not available + * + * @param uri Document uri + * @returns Settings for the document + */ + async getDocumentSettings(uri: string): Promise { + await this.tryUpdateDocumentSettings(uri); + + const settings = this.documentSettings.get(uri) || this.globalSettings; + + await this.tryUpdateAdditionalImports(); + + return addImports(settings, this.additionalImports); + } + + /** + * Remove document settings from cache + * + * @param uri Document uri + */ + removeDocumentSettings(uri: string): void { + this.documentSettings.delete(uri); + } + + /** + * Set flag to request additional imports update. + * This flag will be reset after first successful update. + * NOTE: Imports are updated no more than once in 5 seconds. + */ + requestImportsUpdate() { + this.additionalImportsUpdateRequested = true; + } + + private async tryUpdateDocumentSettings(uri: string): Promise { + if (this.configuration && !this.documentSettings.has(uri)) { + // TODO: Handle errors + const settings = await this.configuration.getConfiguration({ + scopeUri: uri, + section: 'aquaSettings', + }); + if (settings) { + this.documentSettings.set(uri, settings); + } + } + } + + private async tryUpdateAdditionalImports(): Promise { + const now = Date.now(); + // Update additional imports not more often than once in 5 seconds + // and only if there is a request to update + if (now - this.additionalImportsLastUpdated < 5000 || !this.additionalImportsUpdateRequested) { + return; + } + + try { + this.additionalImports = await this.cli.imports(); + this.additionalImportsLastUpdated = now; + this.additionalImportsUpdateRequested = false; + } catch (e) { + // TODO: Handle this more gracefully + console.log('Failed to update additional imports', e); + } + } +} diff --git a/server/src/validation.ts b/server/src/validation.ts index f56ae58..902bb6e 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -7,7 +7,7 @@ import type { WorkspaceFolder } from 'vscode-languageserver-protocol'; import { AquaLSP, ErrorInfo, TokenLink, WarningInfo } from '@fluencelabs/aqua-language-server-api/aqua-lsp-api'; -import type { Settings } from './server'; +import type { Settings } from './settings'; function findNearestNodeModules(fileLocation: string, projectLocation: string): string | undefined { const relative = Path.relative(projectLocation, fileLocation);