diff --git a/README.md b/README.md index e1c0a43..4177103 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/client/src/extension.ts b/client/src/extension.ts index 9d1bb39..b9fe1df 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -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, @@ -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('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')); @@ -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('libraries', []), - }, - }; + const clientOptions = createClientOptions(); // Create the language client and start the client. client = new LanguageClient( @@ -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 { + 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('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('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 | undefined { diff --git a/client/src/test/loadLibraryCommand.test.ts b/client/src/test/loadLibraryCommand.test.ts new file mode 100644 index 0000000..a423840 --- /dev/null +++ b/client/src/test/loadLibraryCommand.test.ts @@ -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.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.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}'`, + ); + }); +}); diff --git a/client/testFixture/UseRuntimeLoadLib.mo b/client/testFixture/UseRuntimeLoadLib.mo new file mode 100644 index 0000000..84add3f --- /dev/null +++ b/client/testFixture/UseRuntimeLoadLib.mo @@ -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; diff --git a/client/testFixtureLibrary/RuntimeLoadLib/M.mo b/client/testFixtureLibrary/RuntimeLoadLib/M.mo new file mode 100644 index 0000000..7a8fa9e --- /dev/null +++ b/client/testFixtureLibrary/RuntimeLoadLib/M.mo @@ -0,0 +1,7 @@ +within RuntimeLoadLib; + +model M + Real x; +equation + x = 1; +end M; diff --git a/client/testFixtureLibrary/RuntimeLoadLib/package.mo b/client/testFixtureLibrary/RuntimeLoadLib/package.mo new file mode 100644 index 0000000..c5f7cfb --- /dev/null +++ b/client/testFixtureLibrary/RuntimeLoadLib/package.mo @@ -0,0 +1,3 @@ +package RuntimeLoadLib "Library loaded at runtime, not at server startup" + annotation(version="1.0.0"); +end RuntimeLoadLib; diff --git a/package.json b/package.json index fa43eee..dd363c2 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,13 @@ ], "main": "./out/client", "contributes": { + "commands": [ + { + "command": "modelica.loadLibrary", + "title": "Load Library", + "category": "Modelica" + } + ], "configuration": { "type": "object", "title": "Modelica Language Server", diff --git a/server/src/analyzer.ts b/server/src/analyzer.ts index a974a36..e513d2e 100644 --- a/server/src/analyzer.ts +++ b/server/src/analyzer.ts @@ -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. * diff --git a/server/src/project/project.ts b/server/src/project/project.ts index 41b1045..94ec3e1 100644 --- a/server/src/project/project.ts +++ b/server/src/project/project.ts @@ -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. * diff --git a/server/src/server.ts b/server/src/server.ts index 6bfe6a8..9ae7a21 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -56,6 +56,10 @@ export class ModelicaServer { #analyzer: Analyzer; #connection: LSP.Connection; #documents: LSP.TextDocuments = new LSP.TextDocuments(TextDocument); + // Absolute, resolved paths of libraries/workspaces already handed to the + // analyzer, so a later notification about the same folder is a no-op + // instead of loading it (and all its documents) a second time. + #loadedLibraryPaths: Set = new Set(); private constructor(analyzer: Analyzer, connection: LSP.Connection) { this.#analyzer = analyzer; @@ -75,15 +79,11 @@ export class ModelicaServer { const parser = await initializeParser(); const analyzer = new Analyzer(parser); + const server = new ModelicaServer(analyzer, connection); + if (workspaceFolders != null) { for (const workspace of workspaceFolders) { - try { - await analyzer.loadLibrary(workspace.uri, true); - } catch (err) { - logger.error( - `Failed to load workspace '${workspace.uri}': ${err instanceof Error ? err.message : err}`, - ); - } + await server.#tryLoadLibrary(workspace.uri, true, `workspace '${workspace.uri}'`); } } @@ -103,26 +103,68 @@ export class ModelicaServer { ]; for (const libraryPath of configuredLibraries) { - const libraryUri = url.pathToFileURL(path.resolve(libraryPath)).toString(); - logger.debug(`Loading configured library '${libraryPath}'`); - try { - const loaded = await analyzer.loadLibrary(libraryUri, false); - if (!loaded) { - const message = - `Could not load Modelica library '${libraryPath}': the path does not exist or has no ` + - `'package.mo'. Remove or fix this entry in the "modelica.libraries" setting to stop loading it.`; - logger.warn(message); - connection.window.showWarningMessage(message); - } - } catch (err) { - logger.error( - `Failed to load configured library '${libraryPath}': ${err instanceof Error ? err.message : err}`, - ); - } + await server.#loadConfiguredLibrary(libraryPath); } logger.debug('Initialized'); - return new ModelicaServer(analyzer, connection); + return server; + } + + /** + * Loads a library or workspace folder into the analyzer, skipping it if + * the same path has already been loaded (e.g. because it was already + * loaded at startup, or a duplicate change notification arrived). + * + * @param uri uri to the library/workspace root + * @param isWorkspace `true` if this is a user workspace/project, `false` if + * this is a library. + * @param description human-readable description for log messages + * @returns `'loaded'`, `'duplicate'` if already loaded, or `'failed'` if + * the path does not exist or has no `package.mo`. + */ + async #tryLoadLibrary( + uri: LSP.URI, + isWorkspace: boolean, + description: string, + ): Promise<'loaded' | 'duplicate' | 'failed'> { + const normalizedPath = path.resolve(url.fileURLToPath(uri)); + if (this.#loadedLibraryPaths.has(normalizedPath)) { + logger.debug(`Skipping ${description}: already loaded.`); + return 'duplicate'; + } + + try { + const loaded = await this.#analyzer.loadLibrary(uri, isWorkspace); + if (loaded) { + this.#loadedLibraryPaths.add(normalizedPath); + return 'loaded'; + } + return 'failed'; + } catch (err) { + logger.error(`Failed to load ${description}: ${err instanceof Error ? err.message : err}`); + return 'failed'; + } + } + + /** + * Resolves a `modelica.libraries`-style path and loads it, warning the + * user via the client if it could not be loaded. + */ + async #loadConfiguredLibrary(libraryPath: string): Promise { + const libraryUri = url.pathToFileURL(path.resolve(libraryPath)).toString(); + logger.debug(`Loading configured library '${libraryPath}'`); + const result = await this.#tryLoadLibrary( + libraryUri, + false, + `configured library '${libraryPath}'`, + ); + if (result === 'failed') { + const message = + `Could not load Modelica library '${libraryPath}': the path does not exist or has no ` + + `'package.mo'. Remove or fix this entry in the "modelica.libraries" setting to stop loading it.`; + logger.warn(message); + this.#connection.window.showWarningMessage(message); + } } /** @@ -162,6 +204,12 @@ export class ModelicaServer { connection.onShutdown(this.onShutdown.bind(this)); connection.onDidChangeTextDocument(this.onDidChangeTextDocument.bind(this)); connection.onDidChangeWatchedFiles(this.onDidChangeWatchedFiles.bind(this)); + connection.onDidChangeConfiguration(this.onDidChangeConfiguration.bind(this)); + // Workspace folder change subscription is done in `onInitialized`, not + // here: subscribing during `onInitialize` (before the server capabilities + // are filled) makes the library send a premature, redundant dynamic + // `client/registerCapability`, which the client rejects and crashes the + // process on the unhandled rejection. connection.onDeclaration(this.onDeclaration.bind(this)); connection.onDefinition(this.onDefinition.bind(this)); connection.onDocumentSymbol(this.onDocumentSymbol.bind(this)); @@ -170,6 +218,25 @@ export class ModelicaServer { private async onInitialized(): Promise { logger.debug('onInitialized'); + + // Subscribe synchronously, before the first `await` below: otherwise a + // `workspace/didChangeWorkspaceFolders` notification arriving while that + // await is pending is delivered before the handler is attached and lost. + // Capabilities are already filled by now, so this sends no dynamic + // registration. It throws if the client didn't advertise the + // `workspace.workspaceFolders` capability, which must not take down the + // rest of the session. + try { + this.#connection.workspace.onDidChangeWorkspaceFolders( + this.onDidChangeWorkspaceFolders.bind(this), + ); + } catch (err) { + logger.warn( + `Client does not support workspace folder change notifications; libraries added ` + + `after startup will require a restart. (${err instanceof Error ? err.message : err})`, + ); + } + await connection.client.register( new LSP.ProtocolNotificationType('workspace/didChangeWatchedFiles'), { @@ -222,6 +289,81 @@ export class ModelicaServer { } } + /** + * Loads libraries added to the workspace and unloads those removed after + * startup, so a client can make a new library available (e.g. to resolve an + * external `within` reference) or free one it no longer needs, without + * restarting the server. + */ + private async onDidChangeWorkspaceFolders( + event: LSP.WorkspaceFoldersChangeEvent, + ): Promise { + logger.debug( + `onDidChangeWorkspaceFolders: +${event.added.length} folder(s), -${event.removed.length} folder(s)`, + ); + + for (const folder of event.added) { + const result = await this.#tryLoadLibrary(folder.uri, true, `workspace folder '${folder.uri}'`); + if (result === 'loaded') { + logger.info(`Loaded newly added workspace folder '${folder.uri}'.`); + } + } + + for (const folder of event.removed) { + this.#unloadLibrary(folder.uri); + } + } + + /** + * Unloads every library under a removed workspace folder and forgets its + * path so the same folder can be added again later. + */ + #unloadLibrary(uri: LSP.URI): void { + let normalizedRoot: string; + try { + normalizedRoot = path.resolve(url.fileURLToPath(uri)); + } catch (err) { + logger.warn( + `Ignoring removed workspace folder with non-file URI '${uri}': ` + + `${err instanceof Error ? err.message : err}`, + ); + return; + } + + const removedPaths = this.#analyzer.unloadLibrary(uri); + this.#loadedLibraryPaths.delete(normalizedRoot); + for (const removed of removedPaths) { + this.#loadedLibraryPaths.delete(path.resolve(removed)); + } + + if (removedPaths.length > 0) { + logger.info( + `Unloaded ${removedPaths.length} librar${removedPaths.length === 1 ? 'y' : 'ies'} ` + + `under removed workspace folder '${uri}'.`, + ); + } else { + logger.debug(`No loaded libraries found under removed workspace folder '${uri}'.`); + } + } + + /** + * Loads libraries added to `modelica.libraries` after startup, so a + * client can push an updated library list without restarting the server. + */ + private async onDidChangeConfiguration(params: LSP.DidChangeConfigurationParams): Promise { + logger.debug('onDidChangeConfiguration'); + const settings = params.settings as { modelica?: { libraries?: unknown } } | undefined; + const libraries = Array.isArray(settings?.modelica?.libraries) + ? settings.modelica.libraries.filter( + (value): value is string => typeof value === 'string' && value.length > 0, + ) + : []; + + for (const libraryPath of libraries) { + await this.#loadConfiguredLibrary(libraryPath); + } + } + // TODO: We currently treat goto declaration and goto definition the same, // but there are probably some differences we need to handle. // diff --git a/server/src/test/analyzer.test.ts b/server/src/test/analyzer.test.ts index 7774354..e1dd64e 100644 --- a/server/src/test/analyzer.test.ts +++ b/server/src/test/analyzer.test.ts @@ -63,3 +63,35 @@ describe('Analyzer.loadLibrary', () => { assert.equal(await analyzer.loadLibrary(missingUri, true), false); }); }); + +describe('Analyzer.unloadLibrary', () => { + const FIXTURES = path.join(__dirname, 'fixtures'); + const LIB_A = path.join(FIXTURES, 'RuntimeLoadLibA'); + const libAUri = url.pathToFileURL(LIB_A).toString(); + + let analyzer: Analyzer; + + beforeEach(async () => { + const parser = await initializeParser(); + analyzer = new Analyzer(parser); + }); + + it('unloads a loaded library and reports its path once', async () => { + assert.equal(await analyzer.loadLibrary(libAUri, false), true); + + assert.deepEqual(analyzer.unloadLibrary(libAUri), [LIB_A]); + // Already gone: a second unload finds nothing to remove. + assert.deepEqual(analyzer.unloadLibrary(libAUri), []); + }); + + it('unloads libraries nested under a removed root', async () => { + assert.equal(await analyzer.loadLibrary(libAUri, false), true); + + const removed = analyzer.unloadLibrary(url.pathToFileURL(FIXTURES).toString()); + assert.ok(removed.includes(LIB_A), `expected ${LIB_A} to be unloaded, got ${JSON.stringify(removed)}`); + }); + + it('returns an empty list when no loaded library matches', () => { + assert.deepEqual(analyzer.unloadLibrary(libAUri), []); + }); +}); diff --git a/server/src/test/fixtures/RuntimeLoadLibA/M.mo b/server/src/test/fixtures/RuntimeLoadLibA/M.mo new file mode 100644 index 0000000..a8e2230 --- /dev/null +++ b/server/src/test/fixtures/RuntimeLoadLibA/M.mo @@ -0,0 +1,7 @@ +within RuntimeLoadLibA; + +model M + Real x; +equation + x = 1; +end M; diff --git a/server/src/test/fixtures/RuntimeLoadLibA/package.mo b/server/src/test/fixtures/RuntimeLoadLibA/package.mo new file mode 100644 index 0000000..ee01f7a --- /dev/null +++ b/server/src/test/fixtures/RuntimeLoadLibA/package.mo @@ -0,0 +1,3 @@ +package RuntimeLoadLibA + annotation(version="1.0.0"); +end RuntimeLoadLibA; diff --git a/server/src/test/fixtures/RuntimeLoadLibB/N.mo b/server/src/test/fixtures/RuntimeLoadLibB/N.mo new file mode 100644 index 0000000..c1d0880 --- /dev/null +++ b/server/src/test/fixtures/RuntimeLoadLibB/N.mo @@ -0,0 +1,5 @@ +within RuntimeLoadLibB; + +model N + extends RuntimeLoadLibA.M; +end N; diff --git a/server/src/test/fixtures/RuntimeLoadLibB/package.mo b/server/src/test/fixtures/RuntimeLoadLibB/package.mo new file mode 100644 index 0000000..869e0d7 --- /dev/null +++ b/server/src/test/fixtures/RuntimeLoadLibB/package.mo @@ -0,0 +1,3 @@ +package RuntimeLoadLibB + annotation(version="1.0.0"); +end RuntimeLoadLibB; diff --git a/server/src/test/fixtures/RuntimeLoadLibC/P.mo b/server/src/test/fixtures/RuntimeLoadLibC/P.mo new file mode 100644 index 0000000..536797f --- /dev/null +++ b/server/src/test/fixtures/RuntimeLoadLibC/P.mo @@ -0,0 +1,5 @@ +within RuntimeLoadLibC; + +model P + extends RuntimeLoadLibA.M; +end P; diff --git a/server/src/test/fixtures/RuntimeLoadLibC/package.mo b/server/src/test/fixtures/RuntimeLoadLibC/package.mo new file mode 100644 index 0000000..0cb60cc --- /dev/null +++ b/server/src/test/fixtures/RuntimeLoadLibC/package.mo @@ -0,0 +1,3 @@ +package RuntimeLoadLibC + annotation(version="1.0.0"); +end RuntimeLoadLibC; diff --git a/server/src/test/runtimeLibraryLoad.test.ts b/server/src/test/runtimeLibraryLoad.test.ts new file mode 100644 index 0000000..f84bf59 --- /dev/null +++ b/server/src/test/runtimeLibraryLoad.test.ts @@ -0,0 +1,548 @@ +/* + * 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. + * + */ + +/* ----------------------------------------------------------------------------- + * End-to-end test driving the bundled server binary (out/server.js) over its + * real stdio JSON-RPC transport, the same way an editor would. Unlike the + * other tests in this directory, this deliberately does not import the + * server module directly: `server.ts` creates a real `LSP.Connection` over + * stdio and calls `connection.listen()` as a side effect of being imported, + * so it can only be safely exercised as a separate process. + * ----------------------------------------------------------------------------- + */ + +import assert from 'node:assert/strict'; +import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +// Built by the root esbuild.config.js (`npm run esbuild` at the repo root), +// which is what CI runs before the test suite. This is NOT the same output +// as `server/out/server.js` (produced by `server`'s own `npm run build`, +// used for the standalone SEA binary). +const SERVER_BUNDLE = path.join(__dirname, '..', '..', '..', 'out', 'server.js'); +const LIB_A = path.join(__dirname, 'fixtures', 'RuntimeLoadLibA'); +const LIB_B = path.join(__dirname, 'fixtures', 'RuntimeLoadLibB'); +const LIB_C = path.join(__dirname, 'fixtures', 'RuntimeLoadLibC'); +const FILE_N = path.join(LIB_B, 'N.mo'); +const FILE_P = path.join(LIB_C, 'P.mo'); + +function fileUri(p: string): string { + return url.pathToFileURL(p).toString(); +} + +interface JsonRpcMessage { + id?: number; + method?: string; + params?: unknown; + result?: unknown; + error?: unknown; +} + +/** A minimal JSON-RPC (LSP framing) client for driving the server bundle over stdio. */ +class LspTestClient { + #child: ChildProcessWithoutNullStreams; + #buffer = Buffer.alloc(0); + #nextId = 1; + #pending = new Map void>(); + #exitCode: number | null | undefined = undefined; + #logs: string[] = []; + + constructor() { + assert.ok( + fs.existsSync(SERVER_BUNDLE), + `Server bundle not found at ${SERVER_BUNDLE}. Run 'npm run esbuild' before the tests.`, + ); + this.#child = spawn(process.execPath, [SERVER_BUNDLE, '--stdio'], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + this.#child.stdout.on('data', (chunk: Buffer) => this.#onData(chunk)); + this.#child.on('exit', (code) => { + this.#exitCode = code; + }); + } + + /** `true` once the server process has exited (e.g. crashed). */ + get hasExited(): boolean { + return this.#exitCode !== undefined; + } + + get exitCode(): number | null | undefined { + return this.#exitCode; + } + + /** All `window/logMessage` texts received from the server so far. */ + get logs(): readonly string[] { + return this.#logs; + } + + /** Waits until a log message containing `substring` arrives, or times out. */ + async waitForLog(substring: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (this.#logs.some((l) => l.includes(substring))) { + return true; + } + await new Promise((r) => setTimeout(r, 25)); + } + return this.#logs.some((l) => l.includes(substring)); + } + + #onData(chunk: Buffer): void { + this.#buffer = Buffer.concat([this.#buffer, chunk]); + for (;;) { + const headerEnd = this.#buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) return; + const header = this.#buffer.subarray(0, headerEnd).toString('utf8'); + const match = /Content-Length: (\d+)/.exec(header); + if (!match) return; + const length = parseInt(match[1], 10); + const bodyStart = headerEnd + 4; + if (this.#buffer.length < bodyStart + length) return; + const body = this.#buffer.subarray(bodyStart, bodyStart + length).toString('utf8'); + this.#buffer = this.#buffer.subarray(bodyStart + length); + + const message = JSON.parse(body) as JsonRpcMessage; + if (message.method === 'window/logMessage') { + const logParams = message.params as { message?: unknown } | undefined; + if (typeof logParams?.message === 'string') { + this.#logs.push(logParams.message); + } + continue; + } + const resolve = message.id !== undefined ? this.#pending.get(message.id) : undefined; + if (message.id !== undefined && resolve) { + this.#pending.delete(message.id); + resolve(message); + } else if (message.id !== undefined && message.method) { + // Server-to-client request. Mimic vscode-languageclient: reject a + // (redundant) dynamic registration of workspace folder change events + // the way the real client does, but accept everything else. A server + // that sends this registration crashes on the unhandled rejection. + const registersWorkspaceFolders = + message.method === 'client/registerCapability' && + JSON.stringify(message.params ?? '').includes('workspace/didChangeWorkspaceFolders'); + if (registersWorkspaceFolders) { + this.#send({ id: message.id, error: { code: -32601, message: 'Unexpected registration' } }); + } else { + this.#send({ id: message.id, result: null }); + } + } + } + } + + #send(message: JsonRpcMessage): void { + const json = JSON.stringify(message); + const header = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n`; + this.#child.stdin.write(header + json); + } + + request(method: string, params: unknown): Promise { + const id = this.#nextId++; + return new Promise((resolve) => { + this.#pending.set(id, resolve); + this.#send({ id, method, params }); + }); + } + + notify(method: string, params: unknown): void { + this.#send({ method, params }); + } + + async dispose(): Promise { + // A crashed server never answers shutdown; don't hang the test on it. + if (!this.hasExited) { + await Promise.race([ + this.request('shutdown', {}), + new Promise((r) => setTimeout(r, 1_000)), + ]); + this.notify('exit', undefined); + } + this.#child.kill(); + } +} + +/** Repeatedly issues `textDocument/definition` until it resolves or the timeout elapses. */ +async function waitForDefinition( + client: LspTestClient, + uri: string, + position: { line: number; character: number }, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastResult: unknown; + do { + const response = await client.request('textDocument/definition', { + textDocument: { uri }, + position, + }); + lastResult = response.result; + if (Array.isArray(lastResult) ? lastResult.length > 0 : lastResult != null) { + return lastResult; + } + await new Promise((r) => setTimeout(r, 50)); + } while (Date.now() < deadline); + return lastResult; +} + +/** Locates the position of `symbol` inside the first line containing `marker`. */ +function positionOf(text: string, marker: string, symbol: string): { line: number; character: number } { + const lines = text.split('\n'); + const line = lines.findIndex((l) => l.includes(marker)); + assert.notEqual(line, -1, `fixture must contain a line with '${marker}'`); + const character = lines[line].indexOf(symbol) + 1; + return { line, character }; +} + +/** Initializes the server with `libB` as the only known workspace folder. */ +async function initializeWithLibB(client: LspTestClient): Promise { + const libBUri = fileUri(LIB_B); + await client.request('initialize', { + processId: process.pid, + rootUri: libBUri, + workspaceFolders: [{ uri: libBUri, name: 'RuntimeLoadLibB' }], + capabilities: { workspace: { workspaceFolders: true, didChangeWorkspaceFolders: true } }, + initializationOptions: { libraries: [] }, + }); + client.notify('initialized', {}); +} + +describe('runtime library loading', () => { + it('resolves an external reference only after the library is announced without a restart', async function () { + this.timeout(20_000); + + const client = new LspTestClient(); + try { + const libBUri = fileUri(LIB_B); + const nUri = fileUri(FILE_N); + const nText = fs.readFileSync(FILE_N, 'utf8'); + const lineIndex = nText.split('\n').findIndex((l) => l.includes('extends RuntimeLoadLibA.M')); + assert.notEqual(lineIndex, -1, 'fixture file must contain the extends clause'); + const characterIndex = nText.split('\n')[lineIndex].indexOf('RuntimeLoadLibA.M') + 1; + const position = { line: lineIndex, character: characterIndex }; + + // Only libB is known at startup; libA (which defines RuntimeLoadLibA.M) is not. + await client.request('initialize', { + processId: process.pid, + rootUri: libBUri, + workspaceFolders: [{ uri: libBUri, name: 'RuntimeLoadLibB' }], + capabilities: { + workspace: { workspaceFolders: true, didChangeWorkspaceFolders: true }, + }, + initializationOptions: { libraries: [] }, + }); + client.notify('initialized', {}); + client.notify('textDocument/didOpen', { + textDocument: { uri: nUri, languageId: 'modelica', version: 1, text: nText }, + }); + + const before = await client.request('textDocument/definition', { + textDocument: { uri: nUri }, + position, + }); + assert.deepEqual( + before.result, + [], + 'RuntimeLoadLibA.M should not resolve before libA is known to the server', + ); + + // Announce the new library the way an editor would when the user adds + // a workspace folder, without restarting the server process. + client.notify('workspace/didChangeWorkspaceFolders', { + event: { added: [{ uri: fileUri(LIB_A), name: 'RuntimeLoadLibA' }], removed: [] }, + }); + + const after = await waitForDefinition(client, nUri, position, 5_000); + assert.ok( + Array.isArray(after) && after.length > 0, + `RuntimeLoadLibA.M should resolve once libA is announced at runtime, got: ${JSON.stringify(after)}`, + ); + } finally { + await client.dispose(); + } + }); + + it('initializes successfully for a client that does not support workspace folder change notifications', async function () { + this.timeout(20_000); + + // `connection.workspace.onDidChangeWorkspaceFolders` throws if the client + // capabilities don't include `workspace.workspaceFolders`. That must not + // fail `initialize` for clients that simply don't support the feature. + const client = new LspTestClient(); + try { + const response = await client.request('initialize', { + processId: process.pid, + rootUri: null, + capabilities: {}, + initializationOptions: { libraries: [] }, + }); + assert.equal(response.error, undefined, JSON.stringify(response.error)); + assert.ok(response.result, 'initialize should return a result'); + } finally { + await client.dispose(); + } + }); + + it('stays alive after initialized when the client rejects a workspace folder registration', async function () { + this.timeout(20_000); + + // Regression: subscribing to workspace folder changes during `initialize` + // sent a premature, redundant dynamic registration. A client that rejects + // it (as vscode-languageclient does, see LspTestClient) crashed the server + // on the unhandled rejection, silently killing every subsequent request. + const client = new LspTestClient(); + try { + const libBUri = fileUri(LIB_B); + const nUri = fileUri(FILE_N); + const nText = fs.readFileSync(FILE_N, 'utf8'); + + await client.request('initialize', { + processId: process.pid, + rootUri: libBUri, + workspaceFolders: [{ uri: libBUri, name: 'RuntimeLoadLibB' }], + capabilities: { workspace: { workspaceFolders: true } }, + initializationOptions: { libraries: [] }, + }); + client.notify('initialized', {}); + client.notify('textDocument/didOpen', { + textDocument: { uri: nUri, languageId: 'modelica', version: 1, text: nText }, + }); + + // Give the server time to process the rejected registration; a buggy + // server crashes here on the unhandled rejection. + await new Promise((r) => setTimeout(r, 500)); + assert.equal( + client.hasExited, + false, + `server process exited (code ${client.exitCode}) after the client rejected a ` + + `workspace folder registration`, + ); + + // And it still answers requests. + const response = await client.request('textDocument/documentSymbol', { + textDocument: { uri: nUri }, + }); + assert.equal(response.error, undefined, JSON.stringify(response.error)); + } finally { + await client.dispose(); + } + }); + + it('resolves an external reference after the library list is pushed via didChangeConfiguration', async function () { + this.timeout(20_000); + + // A client can make a new library available by pushing an updated + // `modelica.libraries` list, without adding a workspace folder. + const client = new LspTestClient(); + try { + const nUri = fileUri(FILE_N); + const nText = fs.readFileSync(FILE_N, 'utf8'); + const position = positionOf(nText, 'extends RuntimeLoadLibA.M', 'RuntimeLoadLibA.M'); + + await initializeWithLibB(client); + client.notify('textDocument/didOpen', { + textDocument: { uri: nUri, languageId: 'modelica', version: 1, text: nText }, + }); + + const before = await client.request('textDocument/definition', { + textDocument: { uri: nUri }, + position, + }); + assert.deepEqual(before.result, [], 'should not resolve before libA is configured'); + + // libB is already loaded (its path repeats here); only libA is new. + client.notify('workspace/didChangeConfiguration', { + settings: { modelica: { libraries: [LIB_A, LIB_B] } }, + }); + + const after = await waitForDefinition(client, nUri, position, 5_000); + assert.ok( + Array.isArray(after) && after.length > 0, + `RuntimeLoadLibA.M should resolve after the config push, got: ${JSON.stringify(after)}`, + ); + assert.equal(client.hasExited, false, `server exited (code ${client.exitCode})`); + } finally { + await client.dispose(); + } + }); + + it('treats re-announcing an already-loaded library as a no-op without disturbing resolution', async function () { + this.timeout(20_000); + + // Loading the same library twice must not re-parse it or break the state + // it already built up; the second announcement should be skipped. + const client = new LspTestClient(); + try { + const nUri = fileUri(FILE_N); + const nText = fs.readFileSync(FILE_N, 'utf8'); + const position = positionOf(nText, 'extends RuntimeLoadLibA.M', 'RuntimeLoadLibA.M'); + const libAFolder = { uri: fileUri(LIB_A), name: 'RuntimeLoadLibA' }; + + await initializeWithLibB(client); + client.notify('textDocument/didOpen', { + textDocument: { uri: nUri, languageId: 'modelica', version: 1, text: nText }, + }); + + client.notify('workspace/didChangeWorkspaceFolders', { + event: { added: [libAFolder], removed: [] }, + }); + const first = await waitForDefinition(client, nUri, position, 5_000); + assert.ok(Array.isArray(first) && first.length > 0, 'libA should resolve after first announce'); + + // Announce the very same folder again. + client.notify('workspace/didChangeWorkspaceFolders', { + event: { added: [libAFolder], removed: [] }, + }); + assert.ok( + await client.waitForLog('already loaded', 5_000), + `expected an "already loaded" log for the duplicate announce; logs: ${client.logs.join(' | ')}`, + ); + + // Resolution must still work after the duplicate announce. + const second = await waitForDefinition(client, nUri, position, 5_000); + assert.ok( + Array.isArray(second) && second.length > 0, + `RuntimeLoadLibA.M should still resolve after a duplicate announce, got: ${JSON.stringify(second)}`, + ); + assert.equal(client.hasExited, false, `server exited (code ${client.exitCode})`); + } finally { + await client.dispose(); + } + }); + + it('loads valid libraries while tolerating an invalid folder announced in the same batch', async function () { + this.timeout(20_000); + + // Two runtime-added libraries where one references the other, mixed with a + // nonexistent folder: the bad entry must neither crash the server nor stop + // the good ones from loading, and cross-library resolution must work. + const client = new LspTestClient(); + try { + const pUri = fileUri(FILE_P); + const pText = fs.readFileSync(FILE_P, 'utf8'); + const position = positionOf(pText, 'extends RuntimeLoadLibA.M', 'RuntimeLoadLibA.M'); + const missing = path.join(LIB_B, 'this-folder-does-not-exist'); + + await initializeWithLibB(client); + client.notify('textDocument/didOpen', { + textDocument: { uri: pUri, languageId: 'modelica', version: 1, text: pText }, + }); + + // Announce a bad folder and both real libraries together. + client.notify('workspace/didChangeWorkspaceFolders', { + event: { + added: [ + { uri: fileUri(missing), name: 'Missing' }, + { uri: fileUri(LIB_A), name: 'RuntimeLoadLibA' }, + { uri: fileUri(LIB_C), name: 'RuntimeLoadLibC' }, + ], + removed: [], + }, + }); + + // P (in libC) extends RuntimeLoadLibA.M (in libA): resolving it proves + // both were loaded despite the invalid sibling. + const after = await waitForDefinition(client, pUri, position, 5_000); + assert.ok( + Array.isArray(after) && after.length > 0, + `cross-library reference should resolve, got: ${JSON.stringify(after)}`, + ); + assert.equal(client.hasExited, false, `server exited (code ${client.exitCode})`); + } finally { + await client.dispose(); + } + }); + + it('unloads a library when its workspace folder is removed, and can reload it afterwards', async function () { + this.timeout(20_000); + + // A long-running session (e.g. OMEdit) can free a library it no longer + // needs: removing the workspace folder unloads it, so references stop + // resolving; announcing it again reloads it from scratch. + const client = new LspTestClient(); + try { + const nUri = fileUri(FILE_N); + const nText = fs.readFileSync(FILE_N, 'utf8'); + const position = positionOf(nText, 'extends RuntimeLoadLibA.M', 'RuntimeLoadLibA.M'); + const libAFolder = { uri: fileUri(LIB_A), name: 'RuntimeLoadLibA' }; + + await initializeWithLibB(client); + client.notify('textDocument/didOpen', { + textDocument: { uri: nUri, languageId: 'modelica', version: 1, text: nText }, + }); + + // Add, then confirm it resolves. + client.notify('workspace/didChangeWorkspaceFolders', { + event: { added: [libAFolder], removed: [] }, + }); + const added = await waitForDefinition(client, nUri, position, 5_000); + assert.ok(Array.isArray(added) && added.length > 0, 'libA should resolve after being added'); + + // Remove the folder: the library is unloaded and no longer resolves. + client.notify('workspace/didChangeWorkspaceFolders', { + event: { added: [], removed: [libAFolder] }, + }); + assert.ok( + await client.waitForLog('Unloaded 1 library', 5_000), + `expected an unload log; logs: ${client.logs.join(' | ')}`, + ); + // Give the unload a moment to take effect, then confirm it stopped resolving. + await new Promise((r) => setTimeout(r, 300)); + const afterRemoval = await client.request('textDocument/definition', { + textDocument: { uri: nUri }, + position, + }); + assert.deepEqual( + afterRemoval.result, + [], + `RuntimeLoadLibA.M should not resolve after libA is unloaded, got: ${JSON.stringify(afterRemoval.result)}`, + ); + + // Re-adding reloads it (proves the remembered path was forgotten on removal). + client.notify('workspace/didChangeWorkspaceFolders', { + event: { added: [libAFolder], removed: [] }, + }); + const readded = await waitForDefinition(client, nUri, position, 5_000); + assert.ok( + Array.isArray(readded) && readded.length > 0, + `RuntimeLoadLibA.M should resolve again after re-adding libA, got: ${JSON.stringify(readded)}`, + ); + assert.equal(client.hasExited, false, `server exited (code ${client.exitCode})`); + } finally { + await client.dispose(); + } + }); +});