Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .vscode-test.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { defineConfig } from '@vscode/test-cli';

const runId = process.env.VSCODE_TEST_RUN_ID ?? 'default';

export default defineConfig({
files: 'out/test/**/*.test.js',
useInstallation: process.env.VSCODE_EXECUTABLE_PATH
? { fromPath: process.env.VSCODE_EXECUTABLE_PATH }
: undefined,
launchArgs: [
'--user-data-dir=/tmp/extension-vscode-test-user-data',
'--extensions-dir=/tmp/extension-vscode-test-extensions'
`--user-data-dir=/tmp/extension-vscode-test-user-data-${runId}`,
`--extensions-dir=/tmp/extension-vscode-test-extensions-${runId}`
],
});
42 changes: 31 additions & 11 deletions src/aspectValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/

import * as vscode from 'vscode';
import type { ExtensionLogger } from './outputChannel';
import type {ExtensionLogger} from './outputChannel';

export const VALIDATE_DOCUMENT_REQUEST = 'turtle/aspectValidation/validateDocument';
export const VALIDATE_DOCUMENT_COMMAND = 'semantic-models.validateDocumentNow';
Expand All @@ -26,13 +26,18 @@ export interface TurtleDiagnostic {
}

export interface DiagnosticReport {
diagnostics: Array<TurtleDiagnostic>
diagnostics: Array<TurtleDiagnostic>;
}

export interface RequestClient {
sendRequest<R>(method: string, params?: unknown): Thenable<R>;
}

type RequestClientBinding = {
client: RequestClient;
generation: number;
};

export interface ValidationWindow {
showInformationMessage(message: string): Thenable<unknown>;
showWarningMessage(message: string): Thenable<unknown>;
Expand All @@ -51,15 +56,19 @@ export interface ValidationWorkspace {
export type ValidationOutputChannel = ExtensionLogger;

export class AspectValidationController {
private binding: RequestClientBinding;

constructor(
private client: RequestClient,
client: RequestClient,
private readonly window: ValidationWindow,
private readonly workspace: ValidationWorkspace,
private readonly outputChannel: ValidationOutputChannel,
) {}
) {
this.binding = {client, generation: 0};
}

setClient(client: RequestClient): void {
this.client = client;
setClient(client: RequestClient, generation = this.binding.generation + 1): void {
this.binding = {client, generation};
}

register(context: vscode.ExtensionContext): void {
Expand All @@ -68,6 +77,9 @@ export class AspectValidationController {
const editor = vscode.window.activeTextEditor;
await this.validateDocument(editor?.document, 'manual');
}),
this.workspace.onDidSaveTextDocument(document => {
void this.validateDocument(document, 'save');
}),
);
}

Expand All @@ -82,27 +94,35 @@ export class AspectValidationController {
return undefined;
}

const binding = this.binding;
const request = () =>
this.client.sendRequest<DiagnosticReport>(VALIDATE_DOCUMENT_REQUEST, {
binding.client.sendRequest<DiagnosticReport>(VALIDATE_DOCUMENT_REQUEST, {
uri: document.uri.toString(),
reason: trigger,
});

return this.runValidation(`document:${document.uri.toString()}`, 'Aspect model validation', trigger, request);
return this.runValidation('Aspect model validation', trigger, binding, request);
}

private async runValidation(
key: string,
title: string,
trigger: AspectValidationTrigger,
binding: RequestClientBinding,
request: () => Thenable<DiagnosticReport>,
): Promise<DiagnosticReport | undefined> {
try {
const result = await this.runWithProgress(title, trigger, request);

if (this.binding !== binding) {
this.outputChannel.info(`[validation] Ignoring stale result from generation ${binding.generation}.`);
return undefined;
}
await this.showSummary(result, trigger);
return result;
} catch (error) {
if (this.binding !== binding) {
this.outputChannel.info(`[validation] Ignoring stale failure from generation ${binding.generation}.`);
return undefined;
}
await this.handleFailure(error, trigger);
return undefined;
}
Expand Down Expand Up @@ -164,7 +184,7 @@ export class AspectValidationController {
if (violationCount === 0) {
return 'Aspect validation completed without issues.';
}
return result.diagnostics.map(x => x.message).join(", ");
return result.diagnostics.map(x => x.message).join(', ');
}

private toFailureMessage(error: unknown): string {
Expand Down
130 changes: 57 additions & 73 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,42 +12,42 @@
*/

import * as vscode from 'vscode';
import { AspectValidationController, RequestClient } from './aspectValidation';
import { TurtleLanguageServer } from './languageServer';
import { SammCliDownloader } from './sammCliDownloader';
import { TurtleExtensionSettings } from './settings';
import { TurtleLanguageClient } from './languageClient';
import { GitHubRepositoryValidator } from './githubRepositoryValidator';
import type { ExtensionLogger } from './outputChannel';
import {AspectValidationController, RequestClient} from './aspectValidation';
import {TurtleLanguageServer} from './languageServer';
import {SammCliDownloader} from './sammCliDownloader';
import {TurtleExtensionSettings} from './settings';
import {TurtleLanguageClient} from './languageClient';
import {GitHubRepositoryValidator} from './githubRepositoryValidator';
import type {ExtensionLogger} from './outputChannel';
import {LanguageServicesSupervisor, LanguageServicesMode, TerminalAction} from './languageServicesSupervisor';

const SELECT_EXECUTABLE_COMMAND = 'semantic-models.selectSammCliExecutable';
const SELECT_EXECUTABLE_TITLE = 'Select SAMM CLI Executable';
const RESTART_LANGUAGE_SERVICES_COMMAND = 'semantic-models.restartLanguageServices';
const GITHUB_REPOSITORY_VALIDATION_DEBOUNCE_MS = 2000;

let settings: TurtleExtensionSettings;
let languageServer: TurtleLanguageServer | undefined;
let languageClient: TurtleLanguageClient;
let aspectValidationController: AspectValidationController;
let sammCliDownloader: SammCliDownloader;
let gitHubRepositoryValidator: GitHubRepositoryValidator;

let outputChannel: ExtensionLogger;
let logOutputChannel: vscode.LogOutputChannel;
let context: vscode.ExtensionContext;
let restartChain: Promise<void> = Promise.resolve();
let languageServicesSupervisor: LanguageServicesSupervisor;
let githubRepositoryValidationTimeout: ReturnType<typeof setTimeout> | undefined;

export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
context = ctx;
const logOutputChannel = vscode.window.createOutputChannel('RDF/Turtle and SAMM Aspect Models Language Server', { log: true });
logOutputChannel = vscode.window.createOutputChannel('RDF/Turtle and SAMM Aspect Models Language Server', {log: true});
context.subscriptions.push(logOutputChannel);
outputChannel = logOutputChannel;
settings = new TurtleExtensionSettings();
sammCliDownloader = new SammCliDownloader(context, settings, outputChannel);
gitHubRepositoryValidator = new GitHubRepositoryValidator(outputChannel);
languageClient = new TurtleLanguageClient(outputChannel, settings.getSammCliLspServerPort(), settings.getLanguageClientTraceLevel());
aspectValidationController = new AspectValidationController(createUnavailableClient(), vscode.window, vscode.workspace, outputChannel);
aspectValidationController.register(context);
languageServicesSupervisor = createLanguageServicesSupervisor();

context.subscriptions.push(
vscode.languages.setLanguageConfiguration('turtle', {
Expand All @@ -56,28 +56,28 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
beforeText: /\.\s*$/,
action: {
indentAction: vscode.IndentAction.Outdent,
appendText: "\n"
appendText: '\n',
},
},
],
})
}),
);

context.subscriptions.push(
vscode.commands.registerCommand(SELECT_EXECUTABLE_COMMAND, async () => {
await selectSammCliExecutable();
}),
vscode.commands.registerCommand(RESTART_LANGUAGE_SERVICES_COMMAND, async () => {
await queueLanguageServicesRestart('Manual restart command');
await languageServicesSupervisor.restart('Manual restart command');
}),
vscode.workspace.onDidChangeConfiguration((e: vscode.ConfigurationChangeEvent) => {
if (e.affectsConfiguration('semantic-models.languageServerSettings')) {
void queueLanguageServicesRestart('Configuration change detected');
void languageServicesSupervisor.restart('Configuration change detected');
}
if (e.affectsConfiguration('semantic-models.modelResolution')) {
scheduleGithubRepositoryValidation();
}
})
}),
);

void validateConfiguredGithubRepositories();
Expand All @@ -93,7 +93,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
return;
}

void queueLanguageServicesRestart('extension activation');
void languageServicesSupervisor.start('extension activation');

if (settings.sammCliAutoUpdateIsEnabled() && settings.isEmbeddedLanguageServerStartEnabled()) {
sammCliDownloader.checkForSammCliUpdates().catch(error => {
Expand All @@ -119,60 +119,46 @@ function scheduleGithubRepositoryValidation(): void {
}, GITHUB_REPOSITORY_VALIDATION_DEBOUNCE_MS);
}

function queueLanguageServicesRestart(reason: string): Promise<void> {
restartChain = restartChain
.then(() => restartLanguageServices(reason))
.catch(async error => {
const selection = await vscode.window.showErrorMessage(`Failed to start required language services: ${error instanceof Error ? error.message : String(error)}`, 'Configure SAMM CLI Executable', 'Check Extension Settings');
if (selection === 'Configure SAMM CLI Executable') {
void selectSammCliExecutable();
} else if (selection === 'Check Extension Settings') {
void vscode.commands.executeCommand('workbench.action.openSettings', 'semantic-models.languageServerSettings');
}
});

return restartChain;
}

async function startLanguageServer(): Promise<void> {
const executablePath = settings.getSammCliPath();
languageServer = new TurtleLanguageServer(context, outputChannel, executablePath, settings.getSammCliLspServerPort());
await languageServer.start();
function createLanguageServicesSupervisor(): LanguageServicesSupervisor {
return new LanguageServicesSupervisor({
configuration: () => ({
mode: settings.isEmbeddedLanguageServerStartEnabled() ? 'embedded' : 'external',
port: settings.getSammCliLspServerPort(),
}),
createServer: configuration => new TurtleLanguageServer(context, outputChannel, settings.getSammCliPath(), configuration.port),
createClient: configuration => new TurtleLanguageClient(outputChannel, configuration.port, settings.getLanguageClientTraceLevel()),
setRequestClient: (client, generation) => aspectValidationController.setClient(client, generation),
unavailableClient: createUnavailableClient,
logger: outputChannel,
notifyTerminal: notifyTerminalRecoveryFailure,
showOutput: () => logOutputChannel.show(true),
showSettings: () => {
void vscode.commands.executeCommand('workbench.action.openSettings', 'semantic-models.languageServerSettings');
},
});
}

async function stopLanguageServer(): Promise<void> {
if (!languageServer) {
return;
async function notifyTerminalRecoveryFailure(mode: LanguageServicesMode): Promise<TerminalAction> {
const guidance =
mode === 'external'
? 'Start or check the separately managed language server, then retry.'
: 'Check the configured SAMM CLI executable and extension settings.';
const selection = await vscode.window.showErrorMessage(
`The Turtle language server could not be recovered after four attempts. ${guidance}`,
'Restart Now',
'Show Language Server Output',
'Check Extension Settings',
);
if (selection === 'Restart Now') {
return 'restart';
}

await languageServer.stop();
languageServer = undefined;
}

async function restartLanguageServices(reason: string): Promise<void> {
outputChannel.info(`Restarting language services (${reason}).`);

aspectValidationController.setClient(createUnavailableClient());
await languageClient.disconnect();
await stopLanguageServer();

try {
if (!settings.isEmbeddedLanguageServerStartEnabled()) {
outputChannel.info('SAMM CLI LSP activation is disabled. Assuming an external server is already running.');
} else {
await startLanguageServer();
}
} catch (error) {
await stopLanguageServer().catch(() => undefined);
aspectValidationController.setClient(createUnavailableClient());

throw error;
if (selection === 'Show Language Server Output') {
return 'output';
}

const nextClient = new TurtleLanguageClient(outputChannel, settings.getSammCliLspServerPort(), settings.getLanguageClientTraceLevel());
await nextClient.connect();
languageClient = nextClient;
aspectValidationController.setClient(nextClient);
if (selection === 'Check Extension Settings') {
return 'settings';
}
return undefined;
}

type SammCliQuickPickItem = vscode.QuickPickItem & {
Expand Down Expand Up @@ -251,11 +237,11 @@ async function selectSammCliExecutable(): Promise<void> {
}

vscode.window.showInformationMessage(restartReason);
await queueLanguageServicesRestart(restartReason);
await languageServicesSupervisor.restart(restartReason);
}

async function promptForDownloadType(): Promise<'native' | 'jar' | undefined> {
const items: Array<vscode.QuickPickItem & { value: 'native' | 'jar' }> = [
const items: Array<vscode.QuickPickItem & {value: 'native' | 'jar'}> = [
{
label: '$(file-binary) Native Executable',
detail: 'Platform-specific binary. No Java required.',
Expand Down Expand Up @@ -302,7 +288,5 @@ export async function deactivate(): Promise<void> {
clearTimeout(githubRepositoryValidationTimeout);
githubRepositoryValidationTimeout = undefined;
}
await restartChain;
await languageClient.disconnect();
await stopLanguageServer();
await languageServicesSupervisor?.dispose();
}
Loading
Loading