diff --git a/client/src/test/gotoDeclaration.test.ts b/client/src/test/gotoDeclaration.test.ts new file mode 100644 index 0000000..bdda157 --- /dev/null +++ b/client/src/test/gotoDeclaration.test.ts @@ -0,0 +1,61 @@ +/* + * This file is part of OpenModelica. + * + * Copyright (c) 1998-2024, 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 { getDocUri, activate } from './helper'; + +suite('Goto Declaration', () => { + test('onDeclaration()', async () => { + const docUri = getDocUri('MyLibrary.mo'); + await activate(docUri); + + const position = new vscode.Position(4, 18); + const actualLocations = await vscode.commands.executeCommand( + 'vscode.executeDeclarationProvider', + docUri, + position, + ); + + assert.strictEqual(actualLocations.length, 1); + + const actualLocation = actualLocations[0]; + assert.strictEqual(actualLocation.targetUri.toString(), docUri.toString()); + assert.strictEqual(actualLocation.targetRange.start.line, 2); + assert.strictEqual(actualLocation.targetRange.start.character, 4); + assert.strictEqual(actualLocation.targetRange.end.line, 2); + assert.strictEqual(actualLocation.targetRange.end.character, 37); + }); +}); diff --git a/package.json b/package.json index 4d9d769..6ae9c64 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "test:e2e": "run-script-os", "test:e2e:win32": "npm run test-compile && powershell -File ./scripts/e2e.ps1", "test:e2e:default": "npm run test-compile && sh ./scripts/e2e.sh", - "test:server": "cd server && npx mocha -r ts-node/register src/test/**/*.test.ts src/project/test/**/*.test.ts src/util/test/**/*.test.ts", + "test:server": "cd server && npx mocha -r ts-node/register src/test/**/*.test.ts src/project/test/**/*.test.ts src/util/test/**/*.test.ts src/analysis/test/**/*.test.ts", "all": "npm run postinstall && npm run esbuild && npm run lint && npm run test:server && npm run test:e2e && npm run vscode:prepublish" }, "devDependencies": { diff --git a/server/src/analysis/reference.ts b/server/src/analysis/reference.ts index 5f9f598..165bdf7 100644 --- a/server/src/analysis/reference.ts +++ b/server/src/analysis/reference.ts @@ -21,6 +21,8 @@ export abstract class BaseUnresolvedReference { } public abstract isAbsolute(): this is UnresolvedAbsoluteReference; + + public abstract equals(other: unknown): boolean; } export class UnresolvedRelativeReference extends BaseUnresolvedReference { @@ -49,6 +51,20 @@ export class UnresolvedRelativeReference extends BaseUnresolvedReference { return false; } + public equals(other: unknown): boolean { + if (!(other instanceof UnresolvedRelativeReference)) { + return false; + } + + return ( + this.document.uri === other.document.uri && + this.node.equals(other.node) && + this.symbols.length === other.symbols.length && + this.symbols.every((s, i) => s === other.symbols[i]) && + this.kind === other.kind + ); + } + public toString(): string { const start = this.node.startPosition; return ( @@ -71,6 +87,18 @@ export class UnresolvedAbsoluteReference extends BaseUnresolvedReference { return true; } + public equals(other: unknown): boolean { + if (!(other instanceof UnresolvedAbsoluteReference)) { + return false; + } + + return ( + this.symbols.length === other.symbols.length && + this.symbols.every((s, i) => s === other.symbols[i]) && + this.kind === other.kind + ); + } + public toString(): string { return ( `UnresolvedReference { ` + @@ -123,6 +151,20 @@ export class ResolvedReference { this.kind = kind; } + public equals(other: unknown): boolean { + if (!(other instanceof ResolvedReference)) { + return false; + } + + return ( + this.document.uri === other.document.uri && + this.node.equals(other.node) && + this.symbols.length === other.symbols.length && + this.symbols.every((s, i) => s === other.symbols[i]) && + this.kind === other.kind + ); + } + public toString(): string { return `Reference { symbols: .${this.symbols.join('.')}, kind: ${this.kind} }`; } diff --git a/server/src/analysis/resolveReference.ts b/server/src/analysis/resolveReference.ts index 0948f4b..cf40c3d 100644 --- a/server/src/analysis/resolveReference.ts +++ b/server/src/analysis/resolveReference.ts @@ -405,6 +405,7 @@ function resolveAbsoluteReference( logger.debug(`Resolving ${reference}`); + logger.debug(project.libraries.map((x) => x.name + ' | ' + x.path).join('\n\t')); const library = project.libraries.find((lib) => lib.name === reference.symbols[0]); if (library == null) { logger.debug(`Couldn't find library: ${reference.symbols[0]}`); @@ -453,7 +454,11 @@ function resolveNext( ): ResolvedReference | null { // If at the root level, find the root package if (!parentReference) { - const documentPath = path.join(library.path, 'package.mo'); + let documentPath = path.join(library.path, 'package.mo'); + if (!fs.existsSync(documentPath)) { + documentPath = path.join(library.path, library.name + '.mo'); + } + const [document, packageClass] = getPackageClassFromFilePath(library, documentPath, nextSymbol); if (!document || !packageClass) { logger.debug(`Couldn't find package class: ${nextSymbol} in ${documentPath}`); diff --git a/server/src/analysis/test/TestLibrary/Constants.mo b/server/src/analysis/test/TestLibrary/Constants.mo new file mode 100644 index 0000000..1d36768 --- /dev/null +++ b/server/src/analysis/test/TestLibrary/Constants.mo @@ -0,0 +1,6 @@ +within TestLibrary; + +package Constants + constant Real e = Modelica.Math.exp(1.0); + constant Real pi = 2 * Modelica.Math.asin(1.0); +end Constants; diff --git a/server/src/analysis/test/TestLibrary/TestPackage/TestClass.mo b/server/src/analysis/test/TestLibrary/TestPackage/TestClass.mo new file mode 100644 index 0000000..9c0f7df --- /dev/null +++ b/server/src/analysis/test/TestLibrary/TestPackage/TestClass.mo @@ -0,0 +1,9 @@ +within TestLibrary.TestPackage; + +import TestLibrary.Constants.pi; + +function TestClass + input Real twoE = 2 * Constants.e; + input Real tau = 2 * pi; + input Real notTau = tau / twoE; +end TestClass; diff --git a/server/src/analysis/test/TestLibrary/TestPackage/package.mo b/server/src/analysis/test/TestLibrary/TestPackage/package.mo new file mode 100644 index 0000000..80e8ffa --- /dev/null +++ b/server/src/analysis/test/TestLibrary/TestPackage/package.mo @@ -0,0 +1,4 @@ +within TestLibrary; + +package TestPackage +end TestPackage; diff --git a/server/src/analysis/test/TestLibrary/package.mo b/server/src/analysis/test/TestLibrary/package.mo new file mode 100644 index 0000000..8493bc1 --- /dev/null +++ b/server/src/analysis/test/TestLibrary/package.mo @@ -0,0 +1,3 @@ +package TestLibrary + annotation(version="1.0.0"); +end TestLibrary; diff --git a/server/src/analysis/test/resolveReference.test.ts b/server/src/analysis/test/resolveReference.test.ts new file mode 100644 index 0000000..2beb8d3 --- /dev/null +++ b/server/src/analysis/test/resolveReference.test.ts @@ -0,0 +1,168 @@ +/* + * This file is part of OpenModelica. + * + * Copyright (c) 1998-2024, 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 Parser from 'web-tree-sitter'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { ModelicaProject, ModelicaLibrary, ModelicaDocument } from '../../project'; +import { initializeParser } from '../../parser'; +import resolveReference from '../resolveReference'; +import { + UnresolvedAbsoluteReference, + UnresolvedRelativeReference, + ResolvedReference, +} from '../reference'; +import * as TreeSitterUtil from '../../util/tree-sitter'; + +const TEST_LIBRARY_PATH = path.join(__dirname, 'TestLibrary'); +const TEST_CLASS_PATH = path.join(TEST_LIBRARY_PATH, 'TestPackage', 'TestClass.mo'); +const CONSTANTS_PATH = path.join(TEST_LIBRARY_PATH, 'Constants.mo'); + +describe('resolveReference', () => { + let project: ModelicaProject; + + beforeEach(async () => { + const parser = await initializeParser(); + project = new ModelicaProject(parser); + project.addLibrary(await ModelicaLibrary.load(project, TEST_LIBRARY_PATH, true)); + }); + + it('should resolve absolute references to classes', async () => { + const unresolved = new UnresolvedAbsoluteReference(['TestLibrary', 'TestPackage', 'TestClass']); + const resolved = resolveReference(project, unresolved, 'declaration'); + + const resolvedDocument = await project.getDocument(TEST_CLASS_PATH); + assert(resolvedDocument !== undefined); + + // Get node declarting `TestClass` + const resolvedNode = TreeSitterUtil.findFirst( + resolvedDocument.tree.rootNode, + (node) => + node.type === 'class_definition' && TreeSitterUtil.getIdentifier(node) === 'TestClass', + )!; + const resolvedSymbols = ['TestLibrary', 'TestPackage', 'TestClass']; + + assert( + resolved?.equals( + new ResolvedReference(resolvedDocument, resolvedNode, resolvedSymbols, 'class'), + ), + ); + }); + + it('should resolve absolute references to variables', async () => { + const unresolved = new UnresolvedAbsoluteReference(['TestLibrary', 'Constants', 'e']); + const resolved = resolveReference(project, unresolved, 'declaration'); + + const resolvedDocument = (await project.getDocument(CONSTANTS_PATH))!; + + // Get the node declaring `e` + const resolvedNode = TreeSitterUtil.findFirst( + resolvedDocument.tree.rootNode, + (node) => + node.type === 'component_clause' && TreeSitterUtil.getDeclaredIdentifiers(node)[0] === 'e', + )!; + const resolvedSymbols = ['TestLibrary', 'Constants', 'e']; + + assert( + resolved?.equals( + new ResolvedReference(resolvedDocument, resolvedNode, resolvedSymbols, 'variable'), + ), + ); + }); + + it('should resolve relative references to locals', async () => { + const document = (await project.getDocument(TEST_CLASS_PATH))!; + const unresolvedNode = TreeSitterUtil.findFirst( + document.tree.rootNode, + (node) => node.startPosition.row === 7 && node.startPosition.column === 21, + )!; + const unresolved = new UnresolvedRelativeReference(document, unresolvedNode, ['tau']); + const resolved = resolveReference(project, unresolved, 'declaration'); + + // the resolved node is the declaration of tau + // `input Real tau = 2 * pi;` + const resolvedNode = TreeSitterUtil.findFirst( + document.tree.rootNode, + (node) => + node.type === 'component_clause' && + TreeSitterUtil.getDeclaredIdentifiers(node)[0] === 'tau', + )!; + + assert( + resolved?.equals( + new ResolvedReference( + document, + resolvedNode, + ['TestLibrary', 'TestPackage', 'TestClass', 'tau'], + 'variable', + ), + ), + ); + }); + + it('should resolve relative references to globals', async () => { + // input Real twoE = 2 * Constants.e; + // ^ 5:33 + const unresolvedDocument = (await project.getDocument(TEST_CLASS_PATH))!; + const unresolvedNode = TreeSitterUtil.findFirst( + unresolvedDocument.tree.rootNode, + (node) => node.startPosition.row === 5 && node.startPosition.column === 33, + )!; + const unresolved = new UnresolvedRelativeReference(unresolvedDocument, unresolvedNode, [ + 'Constants', + 'e', + ]); + const resolved = resolveReference(project, unresolved, 'declaration'); + + const resolvedDocument = (await project.getDocument(CONSTANTS_PATH))!; + // Get the node declaring `e` + const resolvedNode = TreeSitterUtil.findFirst( + resolvedDocument.tree.rootNode, + (node) => + node.type === 'component_clause' && TreeSitterUtil.getDeclaredIdentifiers(node)[0] === 'e', + )!; + + assert( + resolved?.equals( + new ResolvedReference( + resolvedDocument, + resolvedNode, + ['TestLibrary', 'Constants', 'e'], + 'variable', + ), + ), + ); + }); +}); diff --git a/server/src/project/library.ts b/server/src/project/library.ts index fb1175d..dade9bc 100644 --- a/server/src/project/library.ts +++ b/server/src/project/library.ts @@ -33,26 +33,32 @@ * */ -import * as LSP from "vscode-languageserver"; -import * as fsWalk from "@nodelib/fs.walk"; -import * as path from "node:path"; -import * as util from "node:util"; +import * as LSP from 'vscode-languageserver'; +import * as fsWalk from '@nodelib/fs.walk'; +import * as path from 'node:path'; +import * as util from 'node:util'; import { logger } from '../util/logger'; -import { ModelicaDocument } from "./document"; -import { ModelicaProject } from "./project"; +import { ModelicaDocument } from './document'; +import { ModelicaProject } from './project'; export class ModelicaLibrary { readonly #project: ModelicaProject; readonly #documents: Map; readonly #isWorkspace: boolean; + readonly #name: string; #path: string; - public constructor(project: ModelicaProject, libraryPath: string, isWorkspace: boolean) { + public constructor( + project: ModelicaProject, + libraryPath: string, + isWorkspace: boolean, + name?: string, + ) { this.#project = project; - this.#path = libraryPath, - this.#documents = new Map(); + (this.#path = libraryPath), (this.#documents = new Map()); this.#isWorkspace = isWorkspace; + this.#name = name ?? path.basename(this.path); } /** @@ -71,7 +77,11 @@ export class ModelicaLibrary { logger.info(`Loading ${isWorkspace ? 'workspace' : 'library'} at '${libraryPath}'...`); const library = new ModelicaLibrary(project, libraryPath, isWorkspace); - const workspaceRootDocument = await ModelicaDocument.load(project, library, path.join(libraryPath, 'package.mo')); + const workspaceRootDocument = await ModelicaDocument.load( + project, + library, + path.join(libraryPath, 'package.mo'), + ); // Find the root path of the library and update library.#path. // It might have been set incorrectly if we opened a child folder. @@ -96,7 +106,7 @@ export class ModelicaLibrary { } public get name(): string { - return path.basename(this.path); + return this.#name; } public get path(): string { diff --git a/server/src/project/project.ts b/server/src/project/project.ts index 53adec4..30abe0d 100644 --- a/server/src/project/project.ts +++ b/server/src/project/project.ts @@ -44,12 +44,12 @@ import { logger } from '../util/logger'; /** Options for {@link ModelicaProject.getDocument} */ export interface GetDocumentOptions { - /** - * `true` to try loading the document from disk if it is not already loaded. - * - * Default value: `true`. - */ - load?: boolean, + /** + * `true` to try loading the document from disk if it is not already loaded. + * + * Default value: `true`. + */ + load?: boolean; } export class ModelicaProject { @@ -78,7 +78,10 @@ export class ModelicaProject { * @param options * @returns the document, or `undefined` if no such document exists */ - public async getDocument(documentPath: string, options?: GetDocumentOptions): Promise { + public async getDocument( + documentPath: string, + options?: GetDocumentOptions, + ): Promise { let loadedDocument: ModelicaDocument | undefined = undefined; for (const library of this.#libraries) { loadedDocument = library.documents.get(documentPath); @@ -137,8 +140,16 @@ export class ModelicaProject { // If the document doesn't belong to a library, it could still be loaded // as a standalone document if it has an empty or non-existent within clause - const document = await ModelicaDocument.load(this, null, documentPath); + const standaloneName = path.basename(documentPath).split('.')[0]; + const standaloneLibrary = new ModelicaLibrary( + this, + path.dirname(documentPath), + false, + standaloneName, + ); + const document = await ModelicaDocument.load(this, standaloneLibrary, documentPath); if (document.within.length === 0) { + this.addLibrary(standaloneLibrary); logger.debug(`Added document: ${documentPath}`); return document; } @@ -155,7 +166,11 @@ export class ModelicaProject { * @param text the modification * @returns if the document was updated */ - public async updateDocument(documentPath: string, text: string, range?: LSP.Range): Promise { + public async updateDocument( + documentPath: string, + text: string, + range?: LSP.Range, + ): Promise { logger.debug(`Updating document at '${documentPath}'...`); const doc = await this.getDocument(documentPath, { load: true });