feat(vscode): Retrieve imports from fluence CLI [LNG-252] (#49)

* Fix settings definition

* Fix onDidChangeConfiguration event

* Implement settings manager

* Remove undefined

* Refactor
This commit is contained in:
InversionSpaces 2023-10-10 12:47:24 +02:00 committed by GitHub
parent 780246c71e
commit cbff64a0a2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 208 additions and 59 deletions

View File

@ -1,5 +1,5 @@
import * as path from 'path'; import * as path from 'path';
import { ExtensionContext, workspace } from 'vscode'; import type { ExtensionContext } from 'vscode';
import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from 'vscode-languageclient/node'; import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from 'vscode-languageclient/node';
@ -30,10 +30,6 @@ export function activate(context: ExtensionContext) {
const clientOptions: LanguageClientOptions = { const clientOptions: LanguageClientOptions = {
// Register the server for aqua source files // Register the server for aqua source files
documentSelector: [{ pattern: '**/*.aqua' }], 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. // Create the language client and start the client.

View File

@ -62,19 +62,23 @@
"type": "object", "type": "object",
"title": "Aqua", "title": "Aqua",
"properties": { "properties": {
"aquaSettings": { "aquaSettings.imports": {
"imports": { "scope": "resource",
"scope": "resource", "type": "array",
"type": "array", "default": [],
"default": [], "description": "Adds imports for aqua file or project"
"description": "Adds imports for aqua file or project" },
}, "aquaSettings.enableLegacyAutoImportSearch": {
"enableLegacyAutoImportSearch": { "scope": "resource",
"scope": "resource", "type": "boolean",
"type": "boolean", "default": false,
"default": false, "description": "Do not look for extra imports"
"description": "Do not look for extra imports" },
} "aquaSettings.fluencePath": {
"scope": "resource",
"type": ["string", "null"],
"default": null,
"description": "Path to fluence CLI executable"
} }
} }
} }

45
server/src/cli.ts Normal file
View File

@ -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<string[]> {
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<JSON> {
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);
}
});
});
}
}

View File

@ -1,5 +1,7 @@
import { import {
createConnection, createConnection,
DiagnosticSeverity,
DidChangeConfigurationNotification,
InitializeParams, InitializeParams,
InitializeResult, InitializeResult,
ProposedFeatures, 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 type { TokenLink } from '@fluencelabs/aqua-language-server-api/aqua-lsp-api';
import { compileAqua } from './validation'; 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. // Create a connection to the server, using Node's IPC as a transport.
// Also include all preview / proposed LSP features. // Also include all preview / proposed LSP features.
@ -25,11 +29,14 @@ let hasConfigurationCapability = false;
let hasWorkspaceFolderCapability = false; let hasWorkspaceFolderCapability = false;
let folders: WorkspaceFolder[] = []; let folders: WorkspaceFolder[] = [];
export interface Settings { function createSettingsManager(cliPath?: string, defaultSettings?: Settings): SettingsManager {
imports: string[]; const cli = new FluenceCli(cliPath);
enableLegacyAutoImportSearch: boolean; 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 { function searchDefinition(position: Position, name: string, locations: TokenLink[]): TokenLink | undefined {
return locations.find( return locations.find(
(token) => (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<string, Settings> = new Map();
// Cache all locations of all open documents // Cache all locations of all open documents
const allLocations: Map<string, TokenLink[]> = new Map(); const allLocations: Map<string, TokenLink[]> = new Map();
@ -89,37 +89,17 @@ async function onDefinition({ textDocument, position }: DefinitionParams): Promi
connection.onDefinition(onDefinition); connection.onDefinition(onDefinition);
connection.onDidChangeConfiguration((change) => { connection.onDidChangeConfiguration((change) => {
connection.console.log(change.settings); connection.console.log(`onDidChangeConfiguration event ${JSON.stringify(change)}`);
globalSettings = <Settings>(change.settings.aquaSettings || defaultSettings); documentSettings = createSettingsManager(change.settings.aquaSettings.fluencePath, change.settings.aquaSettings);
// Revalidate all open text documents // Revalidate all open text documents
documents.all().forEach(validateDocument); documents.all().forEach(validateDocument);
}); });
async function getDocumentSettings(resource: string): Promise<Settings> {
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 // Only keep settings for open documents
documents.onDidClose((e) => { documents.onDidClose((e) => {
documentSettings.delete(e.document.uri); documentSettings.removeDocumentSettings(e.document.uri);
}); });
connection.onInitialize((params: InitializeParams) => { connection.onInitialize((params: InitializeParams) => {
@ -127,7 +107,6 @@ connection.onInitialize((params: InitializeParams) => {
const capabilities = params.capabilities; const capabilities = params.capabilities;
hasConfigurationCapability = !!(capabilities.workspace && !!capabilities.workspace.configuration); hasConfigurationCapability = !!(capabilities.workspace && !!capabilities.workspace.configuration);
hasWorkspaceFolderCapability = !!(capabilities.workspace && !!capabilities.workspace.workspaceFolders); hasWorkspaceFolderCapability = !!(capabilities.workspace && !!capabilities.workspace.workspaceFolders);
if (params.workspaceFolders) { if (params.workspaceFolders) {
@ -140,6 +119,7 @@ connection.onInitialize((params: InitializeParams) => {
definitionProvider: true, definitionProvider: true,
}, },
}; };
if (hasWorkspaceFolderCapability) { if (hasWorkspaceFolderCapability) {
result.capabilities.workspace = { result.capabilities.workspace = {
workspaceFolders: { workspaceFolders: {
@ -147,15 +127,25 @@ connection.onInitialize((params: InitializeParams) => {
}, },
}; };
} }
return result; return result;
}); });
connection.onInitialized(() => { connection.onInitialized(async () => {
connection.console.log('onInitialized event'); connection.console.log('onInitialized event');
connection.workspace.onDidChangeWorkspaceFolders((event) => {
folders = folders.concat(event.added); if (hasConfigurationCapability) {
folders = folders.filter((f) => !event.removed.includes(f)); 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) => { documents.onDidSave(async (change) => {
@ -169,7 +159,9 @@ documents.onDidOpen(async (change) => {
}); });
async function validateDocument(textDocument: TextDocument): Promise<void> { async function validateDocument(textDocument: TextDocument): Promise<void> {
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); const [diagnostics, locations] = await compileAqua(settings, textDocument, folders, connection.console);
@ -177,6 +169,11 @@ async function validateDocument(textDocument: TextDocument): Promise<void> {
// Send the computed diagnostics to VSCode. // Send the computed diagnostics to VSCode.
connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }); 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 // Make the text document manager listen on the connection

107
server/src/settings.ts Normal file
View File

@ -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<string, Settings> = 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<Settings> {
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<void> {
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<void> {
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);
}
}
}

View File

@ -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 { 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 { function findNearestNodeModules(fileLocation: string, projectLocation: string): string | undefined {
const relative = Path.relative(projectLocation, fileLocation); const relative = Path.relative(projectLocation, fileLocation);