Skip to content
Merged
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ features *Go to Definition* for elements and semantic model validation.
- TCP port used to connect to the SAMM language server.
- `semantic-models.languageServerSettings.traceLevel` (string, default: `off`)
- Controls the verbosity of Language Server Protocol (LSP) tracing. Options: `off`, `messages`, `verbose`.
- `semantic-models.modelResolution.githubRepositories` (array, default: `[]`)
- Additional GitHub repositories to use for Aspect Model Resolution (e.g. `Go to Definition` and validation of models referencing Aspect Models hosted in other repositories). Each entry supports:
- `repository` (string, required) - repository in the format `owner/repository`, e.g. `eclipse-esmf/esmf-sdk`.
- `branch` (string, default: `main`) - branch to resolve Aspect Models from. Must not be set together with `tag`.
- `tag` (string) - tag to resolve Aspect Models from. Must not be set together with `branch`.
- `path` (string, default: `/`) - path inside the repository under which Aspect Models are located.
- `token` (string) - GitHub token for authentication. If omitted, the repository is accessed anonymously.
- Whenever this setting changes, the extension validates that each repository exists and is accessible, and shows an error notification (with a link to the settings) if a problem is found, e.g. a missing repository, invalid/expired token, or exceeded API rate limits.

Use the command `Semantic Models: Select SAMM CLI Executable` to choose either:
- one of the latest SAMM CLI GitHub releases, or
Expand Down
44 changes: 43 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,48 @@
"default": "off",
"description": "Controls the verbosity of Language Server Protocol (LSP) tracing in the output channel.",
"order": 4
},
"semantic-models.modelResolution.githubRepositories": {
"type": "array",
"items": {
"type": "object",
"properties": {
"repository": {
"type": "string",
"default": "",
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
"patternErrorMessage": "Must be in the format \"owner/repository\".",
"markdownDescription": "GitHub repository to use for Aspect Model Resolution. Format: `owner/repository`. Example: `eclipse-esmf/esmf-sdk`."
},
"branch": {
"type": "string",
"default": "main",
"description": "Branch to resolve Aspect Models from. Must not be set together with \"tag\"."
},
"tag": {
"type": "string",
"default": "",
"description": "Tag to resolve Aspect Models from. Must not be set together with \"branch\"."
},
"path": {
"type": "string",
"default": "/",
"description": "Path inside the repository under which Aspect Models are located."
},
"token": {
"type": "string",
"default": "",
"description": "GitHub token used for authentication when accessing this repository. If not provided, the repository will be accessed anonymously."
}
},
"required": [
"repository"
],
"additionalProperties": false
},
"default": [],
"markdownDescription": "Additional GitHub repositories to be used for Aspect Model Resolution. Enables e.g. go to Definition and validation of models that reference Aspect Models in other repositories. Each entry must specify the repository in the format `owner/repository`, and may optionally specify a branch/tag, path, and GitHub token for authentication.",
"order": 5
}
}
},
Expand All @@ -125,7 +167,7 @@
{
"id": "select-samm-cli",
"title": "Select a SAMM CLI Executable",
"description": "Complete the initial setup by downloading or selecting a SAMM CLI executable, which provides the required SAMM Language Server.\n[Download or Select SAMM CLI](command:turtle.selectSammCliExecutable)",
"description": "Complete the initial setup by downloading or selecting a SAMM CLI executable, which provides the required SAMM Language Server.\n[Download or Select SAMM CLI](command:turtle.selectSammCliExecutable)",
"media": {
"image": "media/walkthrough_select_samm_cli.png",
"altText": "Example of selecting a SAMM CLI executable"
Expand Down
31 changes: 31 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,25 @@ 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';

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 context: vscode.ExtensionContext;
let restartChain: Promise<void> = Promise.resolve();
let githubRepositoryValidationTimeout: ReturnType<typeof setTimeout> | undefined;

export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
context = ctx;
Expand All @@ -40,6 +44,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
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);
Expand All @@ -55,9 +60,14 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
if (e.affectsConfiguration('semantic-models.languageServerSettings')) {
void queueLanguageServicesRestart('Configuration change detected');
}
if (e.affectsConfiguration('semantic-models.modelResolution')) {
scheduleGithubRepositoryValidation();
}
})
);

void validateConfiguredGithubRepositories();

if (settings.isEmbeddedLanguageServerStartEnabled() && !settings.getSammCliPath()) {
const selection = await vscode.window.showErrorMessage(
'No SAMM CLI path configured. Required for Language Server functionality.',
Expand All @@ -78,6 +88,23 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
}
}

async function validateConfiguredGithubRepositories(): Promise<void> {
await gitHubRepositoryValidator.validate(settings.getGithubRepositories());
}

// Wait for configuration edits to settle (e.g. while the user is still typing in settings.json)
// before validating, instead of validating after every keystroke.
function scheduleGithubRepositoryValidation(): void {
if (githubRepositoryValidationTimeout) {
clearTimeout(githubRepositoryValidationTimeout);
}

githubRepositoryValidationTimeout = setTimeout(() => {
githubRepositoryValidationTimeout = undefined;
void validateConfiguredGithubRepositories();
}, GITHUB_REPOSITORY_VALIDATION_DEBOUNCE_MS);
}

function queueLanguageServicesRestart(reason: string): Promise<void> {
restartChain = restartChain
.then(() => restartLanguageServices(reason))
Expand Down Expand Up @@ -257,6 +284,10 @@ function createUnavailableClient(): RequestClient {
}

export async function deactivate(): Promise<void> {
if (githubRepositoryValidationTimeout) {
clearTimeout(githubRepositoryValidationTimeout);
githubRepositoryValidationTimeout = undefined;
}
await restartChain;
await languageClient.disconnect();
await stopLanguageServer();
Expand Down
108 changes: 108 additions & 0 deletions src/githubRepositoryValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH
*
* See the AUTHORS file(s) distributed with this work for additional
* information regarding authorship.
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* SPDX-License-Identifier: MPL-2.0
*/

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

/**
* Validates the `semantic-models.modelResolution.githubRepositories` setting by checking, for
* each configured entry, that the repository exists and is accessible using the provided token
* (or anonymously, if none was provided). Problems are logged and surfaced to the user via an
* error notification.
*/
export class GitHubRepositoryValidator {
constructor(private readonly outputChannel: ExtensionLogger) { }

async validate(repositories: Array<GithubRepositoryConfig>): Promise<void> {
if (repositories.length === 0) {
return;
}

const errors = (await Promise.all(repositories.map(repository => this.validateRepository(repository))))
.filter((error): error is string => error !== undefined);

if (errors.length === 0) {
return;
}

const message = errors.length === 1
? errors[0]
: `Multiple issues were found with the configured GitHub repositories for Aspect Model Resolution:\n- ${errors.join('\n- ')}`;

this.outputChannel.error(message);

const selection = await vscode.window.showErrorMessage(message, 'Check Extension Settings');
if (selection === 'Check Extension Settings') {
void vscode.commands.executeCommand('workbench.action.openSettings', 'semantic-models.modelResolution');
}
}

private async validateRepository(repository: GithubRepositoryConfig): Promise<string | undefined> {
if (!repository.repository) {
return 'A GitHub repository entry is missing the required "repository" field.';
}

if (repository.branch && repository.tag) {
return `GitHub repository "${repository.repository}" has both "branch" and "tag" configured; only one of them may be set.`;
}

try {
const response = await fetch(`https://api.github.com/repos/${repository.repository}`, {
headers: this.buildHeaders(repository.token),
});

if (response.ok) {
return undefined;
}

return this.describeError(repository, response);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return `Failed to reach GitHub while validating repository "${repository.repository}": ${message}`;
}
}

private buildHeaders(token: string | undefined): Record<string, string> {
const headers: Record<string, string> = {
'User-Agent': 'esmf-vs-code-plugin',
Accept: 'application/vnd.github+json',
};

if (token) {
headers.Authorization = `Bearer ${token}`;
}

return headers;
}

private describeError(repository: GithubRepositoryConfig, response: Response): string {
const hasToken = !!repository.token;

switch (response.status) {
case 401:
return `The configured token for GitHub repository "${repository.repository}" is invalid or expired.`;
case 403:
if (response.headers.get('x-ratelimit-remaining') === '0') {
const reset = response.headers.get('x-ratelimit-reset');
const resetHint = reset ? ` Try again after ${new Date(Number(reset) * 1000).toLocaleTimeString()}.` : '';
return `GitHub API rate limit exceeded while validating repository "${repository.repository}".${resetHint}`;
}
return `Access to GitHub repository "${repository.repository}" is forbidden${hasToken ? ' with the configured token' : '; it may be private and require a token'}.`;
case 404:
return `GitHub repository "${repository.repository}" was not found or is not accessible${hasToken ? ' with the configured token' : '; it may be private, configure a token if needed'}.`;
default:
return `Failed to validate GitHub repository "${repository.repository}": ${response.status} ${response.statusText}`;
}
}
}
1 change: 1 addition & 0 deletions src/languageClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export class TurtleLanguageClient implements RequestClient {
documentSelector: ['turtle'],
synchronize: {
fileEvents: vscode.workspace.createFileSystemWatcher('**/*.ttl'),
configurationSection: 'semantic-models.modelResolution',
},
};

Expand Down
12 changes: 12 additions & 0 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@

import * as vscode from 'vscode';

export interface GithubRepositoryConfig {
repository: string;
branch: string;
tag: string;
path: string;
token: string;
}

export class TurtleExtensionSettings {

isEmbeddedLanguageServerStartEnabled(): boolean {
Expand Down Expand Up @@ -43,4 +51,8 @@ export class TurtleExtensionSettings {
return vscode.workspace.getConfiguration('semantic-models.languageServerSettings').get<'off' | 'messages' | 'verbose'>('traceLevel', 'off');
}

getGithubRepositories(): Array<GithubRepositoryConfig> {
return vscode.workspace.getConfiguration('semantic-models.modelResolution').get<Array<GithubRepositoryConfig>>('githubRepositories', []);
}

}
Loading