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: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,12 @@ Typical paths:
| Windows | `%APPDATA%\OpenModelica\libraries\` |
| macOS | `~/.openmodelica/libraries/` |

The server loads all configured libraries at startup. Changes take effect after
reloading the VS Code window (**Developer: Reload Window**).
The server loads all configured libraries at startup, and also picks up
libraries added later without a restart: adding a workspace folder, or
pushing an updated `modelica.libraries` list via
`workspace/didChangeConfiguration`, loads the new library into the running
session. Removing a workspace folder does not unload its library yet; a
restart is still required for that.

## Installation

Expand Down
120 changes: 102 additions & 18 deletions client/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,13 @@

import * as path from 'path';
import * as fs from 'fs';
import { workspace, ExtensionContext } from 'vscode';
import {
commands,
window,
workspace,
ConfigurationTarget,
ExtensionContext,
} from 'vscode';
import {
LanguageClient,
LanguageClientOptions,
Expand All @@ -45,6 +51,37 @@ import {

let client: LanguageClient;

/**
* Options controlling the language client.
*
* Exported so tests can assert the configuration synchronization below without
* starting a server.
*/
export function createClientOptions(): LanguageClientOptions {
return {
// Register the server for modelica text documents
documentSelector: [
{
language: 'modelica',
scheme: 'file',
},
],
synchronize: {
// Notify the server about file changes to '.clientrc files contained in the workspace
fileEvents: workspace.createFileSystemWatcher('**/.clientrc'),
// Forward `modelica.*` setting changes as `workspace/didChangeConfiguration`.
// Without this the client sends `{settings: null}` and the server never sees
// libraries added to `modelica.libraries` after startup. Naming the section
// makes the payload `{modelica: {libraries: [...]}}`, which is the shape
// `ModelicaServer.onDidChangeConfiguration` reads.
configurationSection: 'modelica',
},
initializationOptions: {
libraries: workspace.getConfiguration('modelica').get<string[]>('libraries', []),
},
};
}

export function activate(context: ExtensionContext): void {
// The server is implemented in node, point to packed module
const serverModule = context.asAbsolutePath(path.join('out', 'server.js'));
Expand All @@ -62,23 +99,7 @@ export function activate(context: ExtensionContext): void {
},
};

// Options to control the language client
const clientOptions: LanguageClientOptions = {
// Register the server for modelica text documents
documentSelector: [
{
language: 'modelica',
scheme: 'file',
},
],
synchronize: {
// Notify the server about file changes to '.clientrc files contained in the workspace
fileEvents: workspace.createFileSystemWatcher('**/.clientrc'),
},
initializationOptions: {
libraries: workspace.getConfiguration('modelica').get<string[]>('libraries', []),
},
};
const clientOptions = createClientOptions();

// Create the language client and start the client.
client = new LanguageClient(
Expand All @@ -90,6 +111,69 @@ export function activate(context: ExtensionContext): void {

// Start the client. This will also launch the server
client.start();

context.subscriptions.push(
commands.registerCommand('modelica.loadLibrary', loadLibrary),
);
}

/**
* Prompts for Modelica library roots and adds them to `modelica.libraries`.
*
* Writing the setting makes the client send `workspace/didChangeConfiguration`,
* which a running server picks up to load the library without a restart. The
* server skips paths it has already loaded, so re-adding one is harmless, and
* it reports a directory with no `package.mo` back to the user itself.
*/
async function loadLibrary(): Promise<void> {
const picked = await window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: true,
openLabel: 'Load Library',
title: 'Select Modelica library root directories',
});
if (picked === undefined || picked.length === 0) {
return;
}

// Store alongside the project when there is one, so the library list travels
// with it; a workspace write would fail outside a workspace.
const target = workspace.workspaceFolders
? ConfigurationTarget.Workspace
: ConfigurationTarget.Global;

const configuration = workspace.getConfiguration('modelica');
const inspected = configuration.inspect<string[]>('libraries');
// Append to the value at this scope only. Using the effective (merged) value
// would copy entries from the other scopes into this one.
const scoped = (target === ConfigurationTarget.Workspace
? inspected?.workspaceValue
: inspected?.globalValue) ?? [];
const effective = configuration.get<string[]>('libraries', []);

const updated = [...scoped];
const added: string[] = [];
for (const folder of picked) {
if (!effective.includes(folder.fsPath) && !updated.includes(folder.fsPath)) {
updated.push(folder.fsPath);
added.push(folder.fsPath);
}
}

if (added.length === 0) {
window.showInformationMessage(
picked.length === 1
? 'Modelica: that library is already in "modelica.libraries".'
: 'Modelica: those libraries are already in "modelica.libraries".',
);
return;
}

await configuration.update('libraries', updated, target);
window.showInformationMessage(
`Modelica: loading ${added.map((p) => path.basename(p)).join(', ')}.`,
);
}

export function deactivate(): Thenable<void> | undefined {
Expand Down
112 changes: 112 additions & 0 deletions client/src/test/loadLibraryCommand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* This file is part of OpenModelica.
*
* Copyright (c) 1998-2026, Open Source Modelica Consortium (OSMC),
* c/o Linköpings universitet, Department of Computer and Information Science,
* SE-58183 Linköping, Sweden.
*
* All rights reserved.
*
* THIS PROGRAM IS PROVIDED UNDER THE TERMS OF AGPL VERSION 3 LICENSE OR
* THIS OSMC PUBLIC LICENSE (OSMC-PL) VERSION 1.8.
* ANY USE, REPRODUCTION OR DISTRIBUTION OF THIS PROGRAM CONSTITUTES
* RECIPIENT'S ACCEPTANCE OF THE OSMC PUBLIC LICENSE OR THE GNU AGPL
* VERSION 3, ACCORDING TO RECIPIENTS CHOICE.
*
* The OpenModelica software and the OSMC (Open Source Modelica Consortium)
* Public License (OSMC-PL) are obtained from OSMC, either from the above
* address, from the URLs:
* http://www.openmodelica.org or
* https://github.com/OpenModelica/ or
* http://www.ida.liu.se/projects/OpenModelica,
* and in the OpenModelica distribution.
*
* GNU AGPL version 3 is obtained from:
* https://www.gnu.org/licenses/licenses.html#GPL
*
* This program is distributed WITHOUT ANY WARRANTY; without
* even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE, EXCEPT AS EXPRESSLY SET FORTH
* IN THE BY RECIPIENT SELECTED SUBSIDIARY LICENSE CONDITIONS OF OSMC-PL.
*
* See the full OSMC Public License conditions for more details.
*
*/

import * as vscode from 'vscode';
import * as assert from 'assert';
import * as path from 'path';
import { getDocUri, activate, executeProviderUntilResult } from './helper';
import { createClientOptions } from '../extension';

/**
* Guards the `modelica.loadLibrary` command and the configuration
* synchronization it depends on.
*
* The command works by appending to `modelica.libraries`. The client only
* forwards that change to a running server because `synchronize` names
* `configurationSection: 'modelica'`; without it the client sends
* `{settings: null}` and `ModelicaServer.onDidChangeConfiguration` sees no
* libraries, so the command would silently do nothing.
*/
suite('Load Library command', () => {
// Deliberately outside the test workspace (`testFixture`), so the server does
// not pick it up as a workspace folder at startup.
const libraryPath = path.resolve(__dirname, '../../testFixtureLibrary/RuntimeLoadLib');
const configuration = () => vscode.workspace.getConfiguration('modelica');

suiteTeardown(async () => {
await configuration().update('libraries', undefined, vscode.ConfigurationTarget.Workspace);
});

test('registers the modelica.loadLibrary command', async () => {
await activate(getDocUri('UseRuntimeLoadLib.mo'));
const commands = await vscode.commands.getCommands(true);
assert.ok(
commands.includes('modelica.loadLibrary'),
'Expected "modelica.loadLibrary" to be registered',
);
});

test('synchronizes the modelica configuration section with the server', () => {
// Without this the command can still update the setting, but the running
// server is never told, so the library stays unresolvable until a restart.
const synchronize = createClientOptions().synchronize;
assert.strictEqual(
synchronize?.configurationSection, 'modelica',
'Expected synchronize.configurationSection to be "modelica" so that ' +
'modelica.libraries changes reach the server as workspace/didChangeConfiguration',
);
});

test('a library added to modelica.libraries becomes resolvable', async () => {
const docUri = getDocUri('UseRuntimeLoadLib.mo');
await activate(docUri);
// " RuntimeLoadLib.M m;" — cursor on "RuntimeLoadLib"
const position = new vscode.Position(2, 4);

// The library is outside the workspace, so it is not loaded yet.
const before = await vscode.commands.executeCommand<vscode.LocationLink[]>(
'vscode.executeDeclarationProvider', docUri, position,
);
assert.strictEqual(
before?.length ?? 0, 0,
'Expected no declaration before the library is added to "modelica.libraries"',
);

// What the command does.
const libraries: string[] = configuration().get('libraries') ?? [];
await configuration().update(
'libraries', [...libraries, libraryPath], vscode.ConfigurationTarget.Workspace,
);

const after = await executeProviderUntilResult<vscode.LocationLink[]>(
'vscode.executeDeclarationProvider', [docUri, position],
);
assert.ok(after.length > 0, 'Expected the declaration to resolve after adding the library');
assert.ok(
after[0].targetUri.fsPath.startsWith(libraryPath),
`Expected resolution into '${libraryPath}', got '${after[0].targetUri.fsPath}'`,
);
});
});
5 changes: 5 additions & 0 deletions client/testFixture/UseRuntimeLoadLib.mo
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package UseRuntimeLoadLib "Uses a library that is not loaded when the server starts"
model UsesM
RuntimeLoadLib.M m;
end UsesM;
end UseRuntimeLoadLib;
7 changes: 7 additions & 0 deletions client/testFixtureLibrary/RuntimeLoadLib/M.mo
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
within RuntimeLoadLib;

model M
Real x;
equation
x = 1;
end M;
3 changes: 3 additions & 0 deletions client/testFixtureLibrary/RuntimeLoadLib/package.mo
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package RuntimeLoadLib "Library loaded at runtime, not at server startup"
annotation(version="1.0.0");
end RuntimeLoadLib;
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@
],
"main": "./out/client",
"contributes": {
"commands": [
{
"command": "modelica.loadLibrary",
"title": "Load Library",
"category": "Modelica"
}
],
"configuration": {
"type": "object",
"title": "Modelica Language Server",
Expand Down
13 changes: 13 additions & 0 deletions server/src/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,19 @@ export default class Analyzer {
return true;
}

/**
* Unloads every library at or below the given workspace/library root, so a
* client that removed a workspace folder can free the associated libraries
* without restarting the server.
*
* @param uri uri to the library/workspace root
* @returns the file-system paths of the libraries that were unloaded
*/
public unloadLibrary(uri: LSP.URI): string[] {
const rootPath = path.resolve(url.fileURLToPath(uri));
return this.#project.removeLibrariesUnder(rootPath);
}

/**
* Adds a document to the analyzer.
*
Expand Down
25 changes: 25 additions & 0 deletions server/src/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,31 @@ export class ModelicaProject {
this.#libraries.push(library);
}

/**
* Removes every library whose root is at or below `rootPath` (e.g. all
* libraries discovered under a removed workspace folder), dropping their
* loaded documents. Libraries and standalone documents outside `rootPath`
* are left untouched.
*
* @param rootPath absolute path of the removed workspace/library root
* @returns the root paths of the libraries that were removed
*/
public removeLibrariesUnder(rootPath: string): string[] {
const removed: string[] = [];
for (let i = this.#libraries.length - 1; i >= 0; i--) {
const libraryPath = this.#libraries[i].path;
const relative = path.relative(rootPath, libraryPath);
const isAtOrBelow =
libraryPath === rootPath ||
(relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative));
if (isAtOrBelow) {
removed.push(libraryPath);
this.#libraries.splice(i, 1);
}
}
return removed;
}

/**
* Finds the document identified by the given path.
*
Expand Down
Loading
Loading