diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..4edfb95 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,13 @@ +module.exports = { + printWidth: 100, + tabWidth: 2, + useTabs: false, + semi: true, + singleQuote: false, + quoteProps: "as-needed", + trailingComma: "all", + bracketSpacing: true, + arrowParens: "always", + proseWrap: "preserve", + endOfLine: "lf", +}; diff --git a/.vscode/settings.json b/.vscode/settings.json index 2fb4fd9..bded9c2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,15 @@ "typescript.preferences.quoteStyle": "single", "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" - } + }, + "prettier.configPath": "./.prettierrc.js", + "cSpell.words": [ + "metamodelica", + "Modelica", + "nodelib", + "OSMC", + "redeclaration" + ], + "files.insertFinalNewline": true, + "files.trimTrailingWhitespace": true, } diff --git a/server/package-lock.json b/server/package-lock.json index eee8282..7a84e0b 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "license": "OSMC-PL-1-8", "dependencies": { + "@nodelib/fs.walk": "^2.0.0", + "find-cache-dir": "^5.0.0", "tree-sitter": "^0.20.6", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.11", @@ -18,6 +20,38 @@ "node": "20" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-3.0.0.tgz", + "integrity": "sha512-ktI9+PxfHYtKjF3cLTUAh2N+b8MijCRPNwKJNqTVdL0gB0QxLU2rIRaZ1t71oEa3YBDE6bukH1sR0+CDnpp/Mg==", + "dependencies": { + "@nodelib/fs.stat": "3.0.0", + "run-parallel": "^1.2.0" + }, + "engines": { + "node": ">=16.14.0" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-3.0.0.tgz", + "integrity": "sha512-2tQOI38s19P9i7X/Drt0v8iMA+KMsgdhB/dyPER+e+2Y8L1Z7QvnuRdW/uLuf5YRFUYmnj4bMA6qCuZHFI1GDQ==", + "engines": { + "node": ">=16.14.0" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-2.0.0.tgz", + "integrity": "sha512-54voNDBobGdMl3BUXSu7UaDh1P85PGHWlJ5e0XhPugo1JulOyCtp2I+5ri4wplGDJ8QGwPEQW7/x3yTLU7yF1A==", + "dependencies": { + "@nodelib/fs.scandir": "3.0.0", + "fastq": "^1.15.0" + }, + "engines": { + "node": ">=16.14.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -75,6 +109,11 @@ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -121,6 +160,44 @@ "node": ">=6" } }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-cache-dir": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-5.0.0.tgz", + "integrity": "sha512-OuWNfjfP05JcpAP3JPgAKUhWefjMRfI5iAoSsvE24ANYWJaepAtlSgWECSVEuRgSXpyNEc9DJwG/TZpgcOqyig==", + "dependencies": { + "common-path-prefix": "^3.0.0", + "pkg-dir": "^7.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", + "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", + "dependencies": { + "locate-path": "^7.1.0", + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -160,6 +237,20 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, + "node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -224,6 +315,56 @@ "wrappy": "1" } }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/pkg-dir": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", + "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", + "dependencies": { + "find-up": "^6.3.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/prebuild-install": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", @@ -258,6 +399,25 @@ "once": "^1.3.1" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -285,6 +445,37 @@ "node": ">= 6" } }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -481,6 +672,17 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/yocto-queue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/server/src/analysis/reference.ts b/server/src/analysis/reference.ts new file mode 100644 index 0000000..f05c63d --- /dev/null +++ b/server/src/analysis/reference.ts @@ -0,0 +1,129 @@ +import { ModelicaDocument } from "../project/document"; +import Parser from "web-tree-sitter"; + +export type ReferenceKind = "class" | "variable"; + +export abstract class BaseUnresolvedReference { + /** + * The path to the symbol reference. + */ + public readonly symbols: string[]; + + public readonly kind: ReferenceKind | undefined; + + public constructor(symbols: string[], kind?: ReferenceKind) { + if (symbols.length === 0) { + throw new Error("Symbols length must be greater tham 0"); + } + + this.symbols = symbols; + this.kind = kind; + } + + public abstract isAbsolute(): this is UnresolvedAbsoluteReference; +} + +export class UnresolvedRelativeReference extends BaseUnresolvedReference { + /** + * The document that contains the `node`. + */ + public readonly document: ModelicaDocument; + + /** + * A `SyntaxNode` in which the symbol is in scope. + */ + public readonly node: Parser.SyntaxNode; + + public constructor( + document: ModelicaDocument, + node: Parser.SyntaxNode, + symbols: string[], + kind?: ReferenceKind, + ) { + super(symbols, kind); + this.document = document; + this.node = node; + } + + public isAbsolute(): this is UnresolvedAbsoluteReference { + return false; + } + + public toString(): string { + const start = this.node.startPosition; + return ( + `UnresolvedReference { ` + + `symbols: ${this.symbols.join(".")}, ` + + `kind: ${this.kind}, ` + + `position: ${start.row + 1}:${start.column + 1}, ` + + `document: "${this.document.path}" ` + + `}` + ); + } +} + +export class UnresolvedAbsoluteReference extends BaseUnresolvedReference { + public constructor(symbols: string[], kind?: ReferenceKind) { + super(symbols, kind); + } + + public isAbsolute(): this is UnresolvedAbsoluteReference { + return true; + } + + public toString(): string { + return ( + `UnresolvedReference { ` + + `symbols: .${this.symbols.join(".")}, ` + + `kind: ${this.kind} ` + + `}` + ); + } +} + +/** + * A possibly-valid reference to a symbol that must be resolved before use. + */ +export type UnresolvedReference = UnresolvedRelativeReference | UnresolvedAbsoluteReference; + +/** + * A valid, absolute reference to a symbol. + */ +export class ResolvedReference { + /** + * The document that contains the `node`. + */ + readonly document: ModelicaDocument; + + /** + * The node that declares/defines this symbol. + */ + readonly node: Parser.SyntaxNode; + + /** + * The full, absolute path to the symbol. + */ + readonly symbols: string[]; + + readonly kind: ReferenceKind; + + public constructor( + document: ModelicaDocument, + node: Parser.SyntaxNode, + symbols: string[], + kind: ReferenceKind, + ) { + if (symbols.length === 0) { + throw new Error("Symbols length must be greater than 0."); + } + + this.document = document; + this.node = node; + this.symbols = symbols; + this.kind = 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 new file mode 100644 index 0000000..4cc0a1a --- /dev/null +++ b/server/src/analysis/resolveReference.ts @@ -0,0 +1,566 @@ +import Parser from "web-tree-sitter"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +import * as TreeSitterUtil from "../util/tree-sitter"; +import { + ReferenceKind, + ResolvedReference, + UnresolvedAbsoluteReference, + UnresolvedReference, + UnresolvedRelativeReference, +} from "./reference"; +import logger from "../util/logger"; +import { ModelicaProject, ModelicaLibrary, ModelicaDocument } from "../project"; + +export type Resolution = "declaration" | "definition"; + +/** + * Locates the declaration or definition of a symbol reference. + * + * @param project the project + * @param reference a reference + * @param resolution the kind of symbol to search for + */ +export default function resolveReference( + project: ModelicaProject, + reference: UnresolvedReference, + resolution: Resolution, +): ResolvedReference | null { + logger.debug(`Resolving ${resolution} ${reference}`); + + if (resolution === "definition") { + throw new Error("Resolving definitions not yet supported!"); + } + + if (reference instanceof UnresolvedAbsoluteReference) { + return resolveAbsoluteReference(project, reference); + } + + for (const ref of getAbsoluteReferenceCandidates(reference)) { + const resolved = resolveAbsoluteReference(project, ref); + if (resolved) { + return resolved; + } + } + + return null; +} + +/** + * Converts a relative reference to an absolute reference. + * + * @param reference a relative reference to a symbol declaration/definition + * @returns an absolute reference to that symbol, or `null` if no such symbol exists. + */ +function* getAbsoluteReferenceCandidates( + reference: UnresolvedRelativeReference, +): Generator { + logger.debug(`Checking candidates for ${reference}`); + + for (const local of findReferenceInDocument(reference)) { + if (local instanceof UnresolvedAbsoluteReference) { + logger.debug(`Found ${local}`); + yield local; + continue; + } + + const relativeReference = local ?? reference; + + const ancestors: string[] = []; + let currentNode: Parser.SyntaxNode | null = relativeReference.node; + while (currentNode) { + if (currentNode.type === "class_definition") { + const identifier = TreeSitterUtil.getDeclaredIdentifiers(currentNode).at(0); + if (identifier) { + ancestors.unshift(identifier); + } + } + + currentNode = currentNode.parent; + } + + if (relativeReference.node.type === "class_definition") { + ancestors.pop(); + } + + logger.debug(`Found ${relativeReference} with ancestors: [${ancestors}]`); + + const classPath = [...relativeReference.document.within, ...ancestors]; + while (true) { + yield new UnresolvedAbsoluteReference( + [...classPath, ...relativeReference.symbols], + relativeReference.kind, + ); + if (classPath.length === 0) { + break; + } + classPath.pop(); + } + } + + logger.debug(`Didn't find ${reference}`); +} + +/** + * Attempts to locate the definition of an `UnresolvedRelativeReference` within + * the referenced document. + * + * If the reference is present in the document, an `UnresolvedRelativeReference` + * pointing to the definition will be returned. If the reference may refer to an + * import, an `UnresolvedAbsoluteReference` will be returned. If the reference + * was not present at all, `undefined` will be returned. + * + * @param reference a reference to a local in which the `document` and `node` + * properties reference the usage of the symbol. + * @returns the reference candidates + */ +function* findReferenceInDocument( + reference: UnresolvedRelativeReference, +): Generator { + const maybeClass = reference.kind === "class" || reference.kind === undefined; + const maybeVariable = reference.kind === "variable" || reference.kind === undefined; + + if (maybeClass) { + if ( + TreeSitterUtil.isDefinition(reference.node) && + TreeSitterUtil.hasIdentifier(reference.node, reference.symbols[0]) + ) { + yield new UnresolvedRelativeReference( + reference.document, + reference.node, + reference.symbols, + "class", + ); + return; + } + + const classDecl = reference.node.children.find( + (child) => + TreeSitterUtil.isDefinition(child) && + TreeSitterUtil.hasIdentifier(child, reference.symbols[0]), + ); + if (classDecl) { + logger.debug("Found local class"); + yield new UnresolvedRelativeReference( + reference.document, + classDecl, + reference.symbols, + "class", + ); + return; + } + } + + if (maybeVariable) { + const varDecl = reference.node.children.find( + (child) => + TreeSitterUtil.isVariableDeclaration(child) && + TreeSitterUtil.hasIdentifier(child, reference.symbols[0]), + ); + if (varDecl) { + logger.debug("Found local variable"); + yield new UnresolvedRelativeReference( + reference.document, + varDecl, + reference.symbols, + "variable", + ); + return; + } + } + + const declInClass = findDeclarationInClass( + reference.document, + reference.node, + reference.symbols, + reference.kind, + ); + if (declInClass) { + yield declInClass; + return; + } + + const importClauses = reference.node.parent?.children.filter( + (child) => child.type === "import_clause", + ); + if (importClauses && importClauses.length > 0) { + for (const importClause of importClauses) { + const { importCandidate, wildcard } = resolveImportClause(reference.symbols, importClause); + if (importCandidate) { + + if (wildcard) { + yield importCandidate; + } else { + yield importCandidate; + return; + } + } + } + } + + if (reference.node.parent) { + yield* findReferenceInDocument( + new UnresolvedRelativeReference( + reference.document, + reference.node.parent, + reference.symbols, + reference.kind, + ), + ); + return; + } + + logger.debug(`Not found in document. May be a global? ${reference}`); + yield undefined; + return; +} + +/** + * Searches for a declaration within a class (or a superclass). + * + * @param document the class' document + * @param classNode the `class_definition` syntax node referencing the class + * @param symbols the symbol to search for + * @param referenceKind the type of reference + * @returns an unresolved reference to the symbol, or `undefined` if not present + */ +function findDeclarationInClass( + document: ModelicaDocument, + classNode: Parser.SyntaxNode, + symbols: string[], + referenceKind: ReferenceKind | undefined, +): (UnresolvedRelativeReference & { kind: ReferenceKind }) | undefined { + if (classNode.type !== "class_definition") { + return undefined; + } + + const elements = classNode + .childForFieldName("classSpecifier") + ?.children?.filter(TreeSitterUtil.isElementList) + ?.flatMap((element_list) => element_list.namedChildren) + ?.map((element) => [element, TreeSitterUtil.getDeclaredIdentifiers(element)] as const); + + if (!elements) { + logger.debug("Didn't find declaration in class"); + return undefined; + } + + const namedElement = elements.find( + ([element, idents]) => element.type === "named_element" && idents.includes(symbols[0]), + ); + if (namedElement) { + logger.debug(`Resolved ${symbols[0]} to field: ${namedElement[1]}`); + + const classDef = namedElement[0].childForFieldName("classDefinition"); + if (classDef) { + return new UnresolvedRelativeReference( + document, + classDef, + symbols, + "class", + ) as UnresolvedRelativeReference & { kind: "class" }; + } + + const componentDef = namedElement[0].childForFieldName("componentClause")!; + + // TODO: this handles named_elements but what if it's an import clause? + return new UnresolvedRelativeReference( + document, + componentDef, + symbols, + "variable", + ) as UnresolvedRelativeReference & { kind: "variable" }; + } + + // only check superclasses if we know we're not looking for a class + if (referenceKind !== "class") { + const extendsClauses = elements + .map(([element, _idents]) => element) + .filter((element) => element.type === "extends_clause"); + for (const extendsClause of extendsClauses) { + const superclassType = TreeSitterUtil.getTypeSpecifier(extendsClause); + const unresolvedSuperclass = superclassType.isGlobal + ? new UnresolvedAbsoluteReference(superclassType.symbols, "class") + : new UnresolvedRelativeReference(document, extendsClause, superclassType.symbols, "class"); + + logger.debug( + `Resolving superclass ${unresolvedSuperclass} (of ${ + TreeSitterUtil.getDeclaredIdentifiers(classNode)[0] + })`, + ); + + // TODO: support "definition" resolution + const superclass = resolveReference(document.project, unresolvedSuperclass, "declaration"); + if (!superclass) { + logger.warn(`Could not find superclass ${unresolvedSuperclass}`); + continue; + } + + logger.debug(`Checking superclass ${superclass}`); + const decl = findDeclarationInClass( + superclass.document, + superclass.node, + symbols, + "variable", + ); + if (decl) { + logger.debug(`Declaration ${decl} found in superclass ${superclass}`); + return decl; + } + } + } + + return undefined; +} + +interface ResolveImportClauseResult { + /** + * The resolved import candidate, or `undefined` if none was found. + */ + importCandidate?: UnresolvedAbsoluteReference; + /** + * `true` if this was a wildcard import, and we are not sure if this import + * even exists. `false` if this was not a wildcard import. + */ + wildcard: boolean; +} + +/** + * Given an import clause and a potentially-imported symbol, returns an + * unresolved reference to check. + * + * @param symbols a symbol that may have been imported + * @param importClause an import clause + * @returns the resolved import + */ +function resolveImportClause( + symbols: string[], + importClause: Parser.SyntaxNode, +): ResolveImportClauseResult { + // imports are always relative according to the grammar + const importPath = TreeSitterUtil.getTypeSpecifier( + importClause.childForFieldName("name")!, + ).symbols; + + // wildcard import: import a.b.*; + const isWildcard = importClause.childForFieldName("wildcard") != null; + if (isWildcard) { + const importCandidate = new UnresolvedAbsoluteReference([...importPath, ...symbols]); + logger.debug(`Candidate: ${importCandidate} (from import ${importPath.join(".")}.*)`); + + // TODO: this should probably not resolve the reference fully, then immediately + // discard it so it can do so again. + return { importCandidate, wildcard: true }; + } + + // import alias: import z = a.b.c; + // TODO: Determine if import aliases should be counted as "declarations". + // If so, then we should stop here for decls when symbols.length == 1. + const alias = importClause.childForFieldName("alias")?.text; + if (alias && alias === symbols[0]) { + const importCandidate = new UnresolvedAbsoluteReference([...importPath, ...symbols.slice(1)]); + logger.debug(`Candidate: ${importCandidate} (from import ${alias} = ${importPath.join(".")})`); + + return { importCandidate, wildcard: false }; + } + + // multi-import: import a.b.{c, d, e}; + const childImports = importClause + .childForFieldName("imports") + ?.namedChildren?.filter((node) => node.type === "IDENT") + ?.map((node) => node.text); + + if (childImports?.some((name) => name === symbols[0])) { + const importCandidate = new UnresolvedAbsoluteReference([...importPath, ...symbols]); + const importString = `import ${importPath.join(".")}.{ ${childImports.join(", ")} }`; + logger.debug(`Candidate: ${importCandidate} (from ${importString})`); + + return { importCandidate, wildcard: false }; + } + + // normal import: import a.b.c; + if (importPath.at(-1) === symbols[0]) { + const importCandidate = new UnresolvedAbsoluteReference([...importPath, ...symbols.slice(1)]); + logger.debug(`Candidate: ${importCandidate} (from import ${importPath.join(".")})`); + + return { importCandidate, wildcard: false }; + } + + return { wildcard: false }; +} + +/** + * Locates the declaration/definition of an absolute symbol reference. + * + * @param reference an absolute reference + * @returns a resolved reference, or `null` if no such symbol exists + */ +function resolveAbsoluteReference( + project: ModelicaProject, + reference: UnresolvedAbsoluteReference, +): ResolvedReference | null { + if (!(reference instanceof UnresolvedAbsoluteReference)) { + throw new Error(`Reference is not an UnresolvedAbsoluteReference: ${reference}`); + } + + logger.debug(`Resolving ${reference}`); + + const library = project.libraries.find((lib) => lib.name === reference.symbols[0]); + if (library == null) { + logger.debug(`Couldn't find library: ${reference.symbols[0]}`); + return null; + } + + let alreadyResolved: ResolvedReference | null = null; + for (let i = 0; i < reference.symbols.length; i++) { + alreadyResolved = resolveNext(library, reference.symbols[i], alreadyResolved); + if (alreadyResolved == null) { + return null; + } + + // If we're not done with the reference chain, we need to make sure that we + // know the type of the variable in order to check its child variables + if ( + i < reference.symbols.length - 1 && + TreeSitterUtil.isVariableDeclaration(alreadyResolved.node) + ) { + const classRef = variableRefToClassRef(alreadyResolved); + if (classRef == null) { + logger.debug(`Failed to find type of var ${alreadyResolved}`); + return null; + } + + alreadyResolved = classRef; + } + } + + logger.debug(`Resolved symbol ${alreadyResolved}`); + + return alreadyResolved; +} + +/** + * Performs a single iteration of the resolution algorithm. + * + * @param nextSymbol the next symbol to resolve + * @param parentReference a resolved reference (to a class) + * @returns the next resolved reference + */ +function resolveNext( + library: ModelicaLibrary, + nextSymbol: string, + parentReference: ResolvedReference | null, +): ResolvedReference | null { + // If at the root level, find the root package + if (!parentReference) { + const documentPath = path.join(library.path, "package.mo"); + const [document, packageClass] = getPackageClassFromFilePath( + library, + documentPath, + nextSymbol, + ); + if (!document || !packageClass) { + logger.debug(`Couldn't find package class: ${nextSymbol} in ${documentPath}`); + return null; + } + + return new ResolvedReference(document, packageClass, [nextSymbol], "class"); + } + + const dirName = path.dirname(parentReference.document.path); + const potentialPaths = [ + path.join(dirName, `${nextSymbol}.mo`), + path.join(dirName, `${nextSymbol}/package.mo`), + ]; + + for (const documentPath of potentialPaths) { + if (!fs.existsSync(documentPath)) { + continue; + } + + const [document, packageClass] = getPackageClassFromFilePath(library, documentPath, nextSymbol); + if (!document || !packageClass) { + logger.debug(`Couldn't find package class: ${nextSymbol} in ${documentPath}`); + return null; + } + + return new ResolvedReference( + document, + packageClass, + [...parentReference.symbols, nextSymbol], + "class", + ); + } + + // TODO: The `kind` parameter here should be `undefined` unless + // `resolveReference` was called with kind = "class" by the superclass + // handling section in findDeclarationInClass. ...or something like that + // As it is now, we don't know if `child` is a class or variable. We can't use + // `undefined` to indicate this because this results in infinite recursion. + // This issue causes us to be unable to look up variables declared in a + // superclass of a member variable. A redesign might be necessary to resolve + // this. Perhaps if we could keep track of which classes we already visited, + // we wouldn't need the whole "class"/"variable" trick at all! + const child = findDeclarationInClass( + parentReference.document, + parentReference.node, + [nextSymbol], + parentReference.kind, + ); + if (child) { + return new ResolvedReference( + child.document, + child.node, + [...parentReference.symbols, nextSymbol], + child.kind, + ); + } + + logger.debug(`Couldn't find: .${parentReference.symbols.join(".")}.${nextSymbol}`); + + return null; +} + +function getPackageClassFromFilePath( + library: ModelicaLibrary, + filePath: string, + symbol: string, +): [ModelicaDocument | undefined, Parser.SyntaxNode | undefined] { + const document = library.documents.get(filePath); + if (!document) { + logger.debug(`getPackageClassFromFilePath: Couldn't find document ${filePath}`); + return [undefined, undefined]; + } + + const node = TreeSitterUtil.findFirst( + document.tree.rootNode, + (child) => child.type === "class_definition" && TreeSitterUtil.hasIdentifier(child, symbol), + ); + if (!node) { + logger.debug( + `getPackageClassFromFilePath: Couldn't find package class node ${symbol} in ${filePath}`, + ); + return [document, undefined]; + } + + return [document, node]; +} + +/** + * Finds the type of a variable declaration and returns a reference to that + * type. + * + * @param varRef a reference to a variable declaration/definition + * @returns a reference to the class definition, or `null` if the type is not a + * class (e.g. a builtin like `Real`) + */ +function variableRefToClassRef(varRef: ResolvedReference): ResolvedReference | null { + const type = TreeSitterUtil.getTypeSpecifier(varRef.node); + + const typeRef = type.isGlobal + ? new UnresolvedAbsoluteReference(type.symbols, "class") + : new UnresolvedRelativeReference(varRef.document, varRef.node, type.symbols, "class"); + + return resolveReference(varRef.document.project, typeRef, "declaration"); +} diff --git a/server/src/analyzer.ts b/server/src/analyzer.ts index 0dd93cb..ce1ca7f 100644 --- a/server/src/analyzer.ts +++ b/server/src/analyzer.ts @@ -39,60 +39,109 @@ * ----------------------------------------------------------------------------- */ -import * as LSP from 'vscode-languageserver/node'; -import { TextDocument } from 'vscode-languageserver-textdocument'; - -import Parser = require('web-tree-sitter'); +import * as LSP from "vscode-languageserver/node"; +import Parser from "web-tree-sitter"; +import * as fs from "node:fs/promises"; +import * as fsSync from "node:fs"; +import * as path from "node:path"; import { - getAllDeclarationsInTree -} from './util/declarations'; -import { logger } from './util/logger'; - -type AnalyzedDocument = { - document: TextDocument, - declarations: LSP.SymbolInformation[], - tree: Parser.Tree -} + UnresolvedAbsoluteReference, + UnresolvedReference, + UnresolvedRelativeReference, +} from "./analysis/reference"; +import resolveReference from "./analysis/resolveReference"; +import { ModelicaDocument, ModelicaLibrary, ModelicaProject } from "./project"; +import { uriToPath } from "./util"; +import * as TreeSitterUtil from "./util/tree-sitter"; +import { getAllDeclarationsInTree } from "./util/declarations"; +import logger from "./util/logger"; export default class Analyzer { - private parser: Parser; - private uriToAnalyzedDocument: Record = {}; + #project: ModelicaProject; - constructor (parser: Parser) { - this.parser = parser; + public constructor(parser: Parser) { + this.#project = new ModelicaProject(parser); } - public analyze(document: TextDocument): LSP.Diagnostic[] { - logger.debug('analyze:'); + /** + * Adds a library (and all of its documents) to the analyzer. + * + * @param uri uri to the library root + * @param isWorkspace `true` if this is a user workspace/project, `false` if + * this is a library. + */ + public async loadLibrary(uri: LSP.URI, isWorkspace: boolean): Promise { + const isLibrary = (folderPath: string) => + fsSync.existsSync(path.join(folderPath, "package.mo")); - const diagnostics: LSP.Diagnostic[] = []; - const fileContent = document.getText(); - const uri = document.uri; + const libraryPath = uriToPath(uri); + if (!isWorkspace || isLibrary(libraryPath)) { + const lib = await ModelicaLibrary.load(this.#project, libraryPath, isWorkspace); + this.#project.addLibrary(lib); + return; + } - const tree = this.parser.parse(fileContent); - logger.debug(tree.rootNode.toString()); + // TODO: go deeper... something like `TreeSitterUtil.forEach` but for files + // would be good here + for (const nestedRelative of await fs.readdir(libraryPath)) { + const nested = path.resolve(nestedRelative); + if (!isLibrary(nested)) { + continue; + } - // Get declarations - const declarations = getAllDeclarationsInTree(tree, uri); + const library = await ModelicaLibrary.load(this.#project, nested, isWorkspace); + this.#project.addLibrary(library); + } + } - // Update saved analysis for document uri - this.uriToAnalyzedDocument[uri] = { - document, - declarations, - tree - }; + /** + * Adds a document to the analyzer. + * + * Note: {@link loadLibrary} already adds all discovered documents to the + * analyzer. It is only necessary to call this method on file creation. + * + * @param uri uri to document to add + * @throws if the document does not belong to a library + */ + public addDocument(uri: LSP.DocumentUri): void { + this.#project.addDocument(uriToPath(uri)); + } - return diagnostics; + /** + * Submits a modification to a document. Ignores documents that have not been + * added with {@link addDocument} or {@link loadLibrary}. + * + * @param uri uri to document to update + * @param text the modification + * @param range range to update, or `undefined` to replace the whole file + */ + public updateDocument(uri: LSP.DocumentUri, text: string, range?: LSP.Range): void { + this.#project.updateDocument(uriToPath(uri), text, range); } /** - * Get all symbol declarations in the given file. This is used for generating an outline. + * Removes a document from the analyzer. Ignores documents that have not been + * added or have already been removed. * - * TODO: convert to DocumentSymbol[] which is a hierarchy of symbols found in a given text document. + * @param uri uri to document to remove */ - public getDeclarationsForUri(uri: string): LSP.SymbolInformation[] { - const tree = this.uriToAnalyzedDocument[uri]?.tree; + public removeDocument(uri: LSP.DocumentUri): void { + this.#project.removeDocument(uriToPath(uri)); + } + + /** + * Gets all symbol declarations in the given file. This is used for generating + * an outline. + * + * @param uri uri to document + * @returns the symbols + */ + public getDeclarationsForUri(uri: LSP.DocumentUri): LSP.SymbolInformation[] { + // TODO: convert to DocumentSymbol[] which is a hierarchy of symbols found + // in a given text document. + const path = uriToPath(uri); + const tree = this.#project.getDocument(path)?.tree; if (!tree?.rootNode) { return []; @@ -100,4 +149,178 @@ export default class Analyzer { return getAllDeclarationsInTree(tree, uri); } + + /** + * Finds the position of the declaration of the symbol at the given position. + * + * @param uri the opened document + * @param position the cursor position + * @returns a {@link LSP.LocationLink} to the symbol's declaration, or `null` + * if not found. + */ + public findDeclaration( + uri: LSP.DocumentUri, + position: LSP.Position, + ): LSP.LocationLink | null { + const path = uriToPath(uri); + logger.debug( + `Searching for declaration of symbol at ${position.line + 1}:${ + position.character + 1 + } in '${path}'`, + ); + + const document = this.#project.getDocument(path); + if (!document) { + logger.warn(`Couldn't find declaration: document not loaded.`); + return null; + } + + if (!document.tree.rootNode) { + logger.info(`Couldn't find declaration: document has no nodes.`); + return null; + } + + const reference = this.getReferenceAt(document, position); + if (!reference) { + logger.info(`Tried to find declaration in '${path}', but not hovering on any identifiers`); + return null; + } + + logger.debug( + `Searching for '${reference}' at ${position.line + 1}:${position.character + 1} in '${path}'`, + ); + + try { + const result = resolveReference(document.project, reference, "declaration"); + if (!result) { + logger.debug(`Didn't find declaration of ${reference.symbols.join(".")}`); + return null; + } + + const link = TreeSitterUtil.createLocationLink(result.document, result.node); + logger.debug(`Found declaration of ${reference.symbols.join(".")}: `, link); + return link; + } catch (e: unknown) { + if (e instanceof Error) { + logger.debug("Caught exception: ", e.stack); + } else { + logger.debug(`Caught:`, e); + } + return null; + } + } + + /** + * Returns the reference at the document position, or `null` if no reference + * exists. + */ + private getReferenceAt( + document: ModelicaDocument, + position: LSP.Position, + ): UnresolvedReference | null { + function checkBeforeCursor(node: Parser.SyntaxNode): boolean { + if (node.startPosition.row < position.line) { + return true; + } + return ( + node.startPosition.row === position.line && node.startPosition.column <= position.character + ); + } + + const documentOffset = document.offsetAt(position); + + // First, check if this is a `type_specifier` or a `name`. + let hoveredType = this.findNodeAtPosition( + document.tree.rootNode, + documentOffset, + (node) => node.type === "name", + ); + + if (hoveredType) { + if (hoveredType.parent?.type === "type_specifier") { + hoveredType = hoveredType.parent; + } + + const declaredType = TreeSitterUtil.getTypeSpecifier(hoveredType); + const symbols = declaredType.symbolNodes.filter(checkBeforeCursor).map((node) => node.text); + + if (declaredType.isGlobal) { + return new UnresolvedAbsoluteReference(symbols, "class"); + } else { + const startNode = this.findNodeAtPosition( + hoveredType, + documentOffset, + (node) => node.type === "IDENT", + )!; + + return new UnresolvedRelativeReference(document, startNode, symbols, "class"); + } + } + + // Next, check if this is a `component_reference`. + const hoveredComponentReference = this.findNodeAtPosition( + document.tree.rootNode, + documentOffset, + (node) => node.type === "component_reference", + ); + if (hoveredComponentReference) { + // TODO: handle array indices + const componentReference = TreeSitterUtil.getComponentReference(hoveredComponentReference); + const symbols = componentReference.componentNodes + .filter(checkBeforeCursor) + .map((node) => node.text); + + if (componentReference.isGlobal) { + return new UnresolvedAbsoluteReference(symbols, "variable"); + } else { + const startNode = this.findNodeAtPosition( + hoveredComponentReference, + documentOffset, + (node) => node.type === "IDENT", + )!; + + return new UnresolvedRelativeReference(document, startNode, symbols, "variable"); + } + } + + // Finally, give up and check if this is just an ident. + const startNode = this.findNodeAtPosition( + document.tree.rootNode, + documentOffset, + (node) => node.type === "IDENT", + ); + if (startNode) { + return new UnresolvedRelativeReference(document, startNode, [startNode.text]); + } + + // We're not hovering over an identifier. + return null; + } + + /** + * Locates the first node at the given text position that matches the given + * `condition`, starting from the `rootNode`. + * + * Note: it is very important to have some kind of condition. If one tries to + * just accept the first node at that position, this function will always + * return the `rootNode` (or `undefined` if outside the node.) + * + * @param rootNode node to start searching from. parents/siblings of this node will be ignored + * @param offset the offset of the symbol from the start of the document + * @param condition the condition to check if a node is "good" + * @returns the node at the position, or `undefined` if none was found + */ + private findNodeAtPosition( + rootNode: Parser.SyntaxNode, + offset: number, + condition: (node: Parser.SyntaxNode) => boolean, + ): Parser.SyntaxNode | undefined { + // TODO: find the deepest node. findFirst doesn't work (maybe?) + const hoveredNode = TreeSitterUtil.findFirst(rootNode, (node) => { + const isInNode = offset >= node.startIndex && offset <= node.endIndex; + return isInNode && condition(node); + }); + + return hoveredNode ?? undefined; + } } diff --git a/server/src/project/document.ts b/server/src/project/document.ts new file mode 100644 index 0000000..a40fb82 --- /dev/null +++ b/server/src/project/document.ts @@ -0,0 +1,210 @@ +/* + * 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 { TextDocument } from "vscode-languageserver-textdocument"; +import * as LSP from "vscode-languageserver/node"; +import Parser from "web-tree-sitter"; +import * as fs from "node:fs/promises"; +import * as url from "node:url"; +import * as path from "node:path"; + +import { logger } from "../util/logger"; +import { positionToPoint } from "../util/tree-sitter"; +import { ModelicaLibrary } from "./library"; +import { ModelicaProject } from "./project"; +import { pathToUri, uriToPath } from "../util"; + +export class ModelicaDocument implements TextDocument { + readonly #library: ModelicaLibrary; + readonly #document: TextDocument; + #tree: Parser.Tree; + + private constructor(library: ModelicaLibrary, document: TextDocument, tree: Parser.Tree) { + this.#library = library; + this.#document = document; + this.#tree = tree; + } + + /** + * Loads a document. + * + * @param library the containing {@link ModelicaLibrary} + * @param documentPath the path to the document + * @returns the document + */ + public static async load( + library: ModelicaLibrary, + documentPath: string, + ): Promise { + logger.debug(`Loading document at '${documentPath}'...`); + + const content = await fs.readFile(documentPath, "utf-8"); + // On caching: see issue https://github.com/tree-sitter/tree-sitter/issues/824 + // TL;DR: it's faster to re-parse the content than it is to deserialize the cached tree. + const tree = library.project.parser.parse(content); + + return new ModelicaDocument( + library, + TextDocument.create(pathToUri(documentPath), "modelica", 0, content), + tree + ); + } + + /** + * Incrementally updates a document. + * + * @param text the modification + * @param range the range to update, or `undefined` to replace the whole file + */ + public async update(text: string, range?: LSP.Range): Promise { + if (range === undefined) { + TextDocument.update(this.#document, [{ text }], this.version + 1); + this.#tree = this.project.parser.parse(text); + return; + } + + const startIndex = this.offsetAt(range.start); + const startPosition = positionToPoint(range.start); + const oldEndIndex = this.offsetAt(range.end); + const oldEndPosition = positionToPoint(range.end); + const newEndIndex = startIndex + text.length; + + TextDocument.update(this.#document, [{ text, range }], this.version + 1); + const newEndPosition = positionToPoint(this.positionAt(newEndIndex)); + + this.#tree.edit({ + startIndex, + startPosition, + oldEndIndex, + oldEndPosition, + newEndIndex, + newEndPosition, + }); + + this.#tree = this.project.parser.parse((index: number, position?: Parser.Point) => { + if (position) { + return this.getText({ + start: { + character: position.column, + line: position.row, + }, + end: { + character: position.column + 1, + line: position.row, + }, + }); + } else { + return this.getText({ + start: this.positionAt(index), + end: this.positionAt(index + 1), + }); + } + }, this.#tree); + } + + public getText(range?: LSP.Range | undefined): string { + return this.#document.getText(range); + } + + public positionAt(offset: number): LSP.Position { + return this.#document.positionAt(offset); + } + + public offsetAt(position: LSP.Position): number { + return this.#document.offsetAt(position); + } + + public get uri(): LSP.DocumentUri { + return this.#document.uri; + } + + public get path(): string { + return uriToPath(this.#document.uri); + } + + public get languageId(): string { + return this.#document.languageId; + } + + public get version(): number { + return this.#document.version; + } + + public get lineCount(): number { + return this.#document.lineCount; + } + + /** + * The fully-qualified name of the class declared by this file. For instance, + * for a file named `MyLibrary/MyPackage/MyClass.mo`, this would be + * `["MyLibrary", "MyPackage", "MyClass"]`. + */ + public get packagePath(): string[] { + const directories = path.relative(this.#library.path, this.path).split(path.sep); + const fileName = directories.pop()!; + + const packagePath: string[] = [this.#library.name, ...directories]; + if (fileName !== "package.mo") { + packagePath.push(fileName.slice(0, fileName.length - ".mo".length)); + } + + return packagePath; + } + + /** + * The enclosing package of the class declared by this file. For instance, for + * a file named `MyLibrary/MyPackage/MyClass.mo`, this would be `["MyLibrary", + * "MyPackage"]`. + * + * Note: this property should be the same thing as the `within` clause + * declared in the document. However, we don't actually check the clause at + * all. The `within` clause is entirely redundant and completely ignored. + */ + public get within(): string[] { + return this.packagePath.slice(0, -1); + } + + public get project(): ModelicaProject { + return this.#library.project; + } + + public get library(): ModelicaLibrary { + return this.#library; + } + + public get tree(): Parser.Tree { + return this.#tree; + } +} diff --git a/server/src/project/index.ts b/server/src/project/index.ts new file mode 100644 index 0000000..a80780b --- /dev/null +++ b/server/src/project/index.ts @@ -0,0 +1,3 @@ +export { ModelicaDocument } from "./document"; +export { ModelicaLibrary } from "./library"; +export { ModelicaProject } from "./project"; diff --git a/server/src/project/library.ts b/server/src/project/library.ts new file mode 100644 index 0000000..a4e26d1 --- /dev/null +++ b/server/src/project/library.ts @@ -0,0 +1,117 @@ +/* + * 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 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"; + +export class ModelicaLibrary { + readonly #project: ModelicaProject; + readonly #documents: Map; + readonly #isWorkspace: boolean; + #path: string; + + private constructor(project: ModelicaProject, libraryPath: string, isWorkspace: boolean) { + this.#project = project; + this.#path = libraryPath, + this.#documents = new Map(); + this.#isWorkspace = isWorkspace; + } + + /** + * Loads a library and all of its {@link ModelicaDocument}s. + * + * @param project the containing project + * @param libraryPath the path to the library + * @param isWorkspace `true` if this is a user workspace + * @returns the loaded library + */ + public static async load( + project: ModelicaProject, + libraryPath: string, + isWorkspace: boolean, + ): Promise { + logger.info(`Loading ${isWorkspace ? 'workspace' : 'library'} at '${libraryPath}'...`); + + const library = new ModelicaLibrary(project, libraryPath, isWorkspace); + const workspaceRootDocument = await ModelicaDocument.load(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. + for (let i = 0; i < workspaceRootDocument.packagePath.length - 1; i++) { + library.#path = path.dirname(library.#path); + } + + logger.debug(`Set library path to ${library.path}`); + + const walk = util.promisify(fsWalk.walk); + const entries = await walk(library.#path, { + entryFilter: (entry) => !!entry.name.match(/.*\.mo/) && !entry.dirent.isDirectory(), + }); + + for (const entry of entries) { + const document = await ModelicaDocument.load(library, entry.path); + library.#documents.set(entry.path, document); + } + + logger.debug(`Loaded ${library.#documents.size} documents`); + return library; + } + + public get name(): string { + return path.basename(this.path); + } + + public get path(): string { + return this.#path; + } + + public get project(): ModelicaProject { + return this.#project; + } + + public get documents(): Map { + return this.#documents; + } + + public get isWorkspace(): boolean { + return this.#isWorkspace; + } +} diff --git a/server/src/project/project.ts b/server/src/project/project.ts new file mode 100644 index 0000000..7531b95 --- /dev/null +++ b/server/src/project/project.ts @@ -0,0 +1,153 @@ +/* + * 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 * as LSP from "vscode-languageserver"; +import url from "node:url"; +import path from "node:path"; + +import { ModelicaLibrary } from "./library"; +import { ModelicaDocument } from './document'; +import logger from "../util/logger"; + +export class ModelicaProject { + readonly #parser: Parser; + readonly #libraries: ModelicaLibrary[]; + + public constructor(parser: Parser) { + this.#parser = parser; + this.#libraries = []; + } + + public get libraries(): ModelicaLibrary[] { + return this.#libraries; + } + + public addLibrary(library: ModelicaLibrary) { + this.#libraries.push(library); + } + + /** + * Finds the document identified by the given uri. + * + * @param uri file:// uri pointing to the document + * @returns the document, or `undefined` if no such document exists + */ + public getDocument(documentPath: string): ModelicaDocument | undefined { + for (const library of this.#libraries) { + const doc = library.documents.get(documentPath); + if (doc) { + logger.debug(`Found document: ${doc.path}`); + return doc; + } + } + + logger.debug(`Couldn't find document: ${documentPath}`); + + return undefined; + } + + /** + * Adds a new document to the LSP. Calling this method multiple times for the + * same document has no effect. + * + * @param documentPath path to the document + * @throws if the document does not belong to a library + */ + public async addDocument(documentPath: string): Promise { + logger.info(`Adding document at '${documentPath}'...`); + + for (const library of this.#libraries) { + const relative = path.relative(library.path, documentPath); + const isSubdirectory = relative && !relative.startsWith("..") && !path.isAbsolute(relative); + + // Assume that files can't be inside multiple libraries at the same time + if (isSubdirectory) { + if (library.documents.get(documentPath) !== undefined) { + logger.warn(`Document '${documentPath}' already in library '${library.name}'; ignoring...`); + return; + } + + const document = await ModelicaDocument.load(library, documentPath); + library.documents.set(documentPath, document); + logger.debug(`Added document: ${documentPath}`); + return; + } + } + + throw new Error(`Failed to add document '${documentPath}': not a part of any libraries.`); + } + + /** + * Incrementally updates the content and tree of the given document. Ignores + * documents that have not been loaded. + * + * @param text the modification + * @param range range to update, or `undefined` to replace the whole file + */ + public updateDocument(documentPath: string, text: string, range?: LSP.Range): void { + logger.debug(`Updating document at '${documentPath}'...`); + + const doc = this.getDocument(documentPath); + if (doc) { + doc.update(text, range); + logger.debug(`Updated document '${documentPath}'`); + } else { + logger.warn(`Failed to update document '${documentPath}': not loaded`); + } + } + + /** + * Removes a document from the cache. + */ + public removeDocument(documentPath: string): void { + logger.info(`Removing document at '${documentPath}'...`); + + const doc = this.getDocument(documentPath); + if (doc) { + doc.library.documents.delete(documentPath); + } else { + logger.warn(`Failed to remove document '${documentPath}': not loaded`); + } + } + + public get parser(): Parser { + return this.#parser; + } + + public get project(): ModelicaProject { + return this; + } +} diff --git a/server/src/server.ts b/server/src/server.ts index 4bc00e4..2ec07d2 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -39,18 +39,21 @@ * ----------------------------------------------------------------------------- */ -import * as LSP from 'vscode-languageserver/node'; -import { TextDocument} from 'vscode-languageserver-textdocument'; +import * as LSP from "vscode-languageserver/node"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import * as fs from "node:fs/promises"; -import { initializeParser } from './parser'; -import Analyzer from './analyzer'; -import { logger, setLogConnection, setLogLevel } from './util/logger'; +import { initializeParser } from "./parser"; +import Analyzer from "./analyzer"; +import { uriToPath } from "./util"; +import { logger, setLogConnection, setLogLevel } from "./util/logger"; /** * ModelicaServer collection all the important bits and bobs. */ export class ModelicaServer { - analyzer: Analyzer; + private initialized = false; + private analyzer: Analyzer; private clientCapabilities: LSP.ClientCapabilities; private connection: LSP.Connection; private documents: LSP.TextDocuments = new LSP.TextDocuments(TextDocument); @@ -58,7 +61,7 @@ export class ModelicaServer { private constructor( analyzer: Analyzer, clientCapabilities: LSP.ClientCapabilities, - connection: LSP.Connection + connection: LSP.Connection, ) { this.analyzer = analyzer; this.clientCapabilities = clientCapabilities; @@ -67,107 +70,199 @@ export class ModelicaServer { public static async initialize( connection: LSP.Connection, - { capabilities }: LSP.InitializeParams, + { capabilities, workspaceFolders }: LSP.InitializeParams, ): Promise { - // Initialize logger setLogConnection(connection); - setLogLevel('debug'); - logger.debug('Initializing...'); + setLogLevel("debug"); + logger.debug("Initializing..."); const parser = await initializeParser(); const analyzer = new Analyzer(parser); + if (workspaceFolders != null) { + for (const workspace of workspaceFolders) { + await analyzer.loadLibrary(workspace.uri, true); + } + } + // TODO: add libraries as well - const server = new ModelicaServer(analyzer, capabilities, connection); - - logger.debug('Initialized'); - return server; + logger.debug("Initialized"); + return new ModelicaServer(analyzer, capabilities, connection); } /** * Return what parts of the language server protocol are supported by ModelicaServer. */ - public capabilities(): LSP.ServerCapabilities { + public get capabilities(): LSP.ServerCapabilities { return { - textDocumentSync: LSP.TextDocumentSyncKind.Full, + colorProvider: false, completionProvider: undefined, + declarationProvider: true, + definitionProvider: true, + documentSymbolProvider: true, hoverProvider: false, signatureHelpProvider: undefined, - documentSymbolProvider: true, - colorProvider: false, - semanticTokensProvider: undefined + semanticTokensProvider: undefined, + textDocumentSync: LSP.TextDocumentSyncKind.Incremental, + workspace: { + workspaceFolders: { + supported: true, + changeNotifications: true, + }, + }, }; } - public register(connection: LSP.Connection): void { - - let currentDocument: TextDocument | null = null; - let initialized = false; - + public async register(connection: LSP.Connection): Promise { // Make the text document manager listen on the connection // for open, change and close text document events this.documents.listen(this.connection); + connection.onShutdown(this.onShutdown.bind(this)); connection.onDocumentSymbol(this.onDocumentSymbol.bind(this)); + connection.onInitialized(this.onInitialized.bind(this)); + connection.onDidChangeTextDocument(this.onDidChangeTextDocument.bind(this)); + connection.onDidChangeWatchedFiles(this.onDidChangeWatchedFiles.bind(this)); + connection.onDeclaration(this.onDeclaration.bind(this)); + connection.onDefinition(this.onDefinition.bind(this)); + } - connection.onInitialized(async () => { - initialized = true; - if (currentDocument) { - // If we already have a document, analyze it now that we're initialized - // and the linter is ready. - this.analyzeDocument(currentDocument); - } - }); + private async onInitialized(): Promise { + logger.debug("onInitialized"); + this.initialized = true; + + await connection.client.register( + new LSP.ProtocolNotificationType("workspace/didChangeWatchedFiles"), + { + watchers: [ + { + globPattern: "**/*.{mo,mos}", + }, + ], + }, + ); + + // If we opened a project, analyze it now that we're initialized + // and the linter is ready. + + // TODO: analysis + } + + private async onShutdown(): Promise { + logger.debug("onShutdown"); + } - // The content of a text document has changed. This event is emitted - // when the text document first opened or when its content has changed. - this.documents.onDidChangeContent(({ document }) => { - logger.debug('onDidChangeContent'); + private async onDidChangeTextDocument(params: LSP.DidChangeTextDocumentParams): Promise { + logger.debug("onDidChangeTextDocument"); + for (const change of params.contentChanges) { + const range = "range" in change ? change.range : undefined; + this.analyzer.updateDocument(params.textDocument.uri, change.text, range); + } + } - // We need to define some timing to wait some time or until whitespace is typed - // to update the tree or we are doing this on every key stroke + private async onDidChangeWatchedFiles(params: LSP.DidChangeWatchedFilesParams): Promise { + logger.debug("onDidChangeWatchedFiles: " + JSON.stringify(params, undefined, 4)); - currentDocument = document; - if (initialized) { - this.analyzeDocument(document); + for (const change of params.changes) { + switch (change.type) { + case LSP.FileChangeType.Created: + this.analyzer.addDocument(change.uri); + break; + case LSP.FileChangeType.Changed: { + // TODO: incremental? + const path = uriToPath(change.uri); + const content = await fs.readFile(path, "utf-8"); + this.analyzer.updateDocument(change.uri, content); + break; + } + case LSP.FileChangeType.Deleted: { + this.analyzer.removeDocument(change.uri); + break; + } } - }); + } } + // TODO: We currently treat goto declaration and goto definition the same, + // but there are probably some differences we need to handle. + // + // 1. inner/outer variables. Modelica allows the user to redeclare variables + // from enclosing classes to use them in inner classes. Goto Declaration + // should go to whichever declaration is in scope, while Goto Definition + // should go to the `outer` declaration. In the following example: + // + // model Outer + // model Inner + // inner Real shared; + // equation + // shared = ...; (A) + // end Inner; + // outer Real shared = 0; + // equation + // shared = ...; (B) + // end Outer; + // + // +-----+-------------+------------+ + // | Ref | Declaration | Definition | + // +-----+-------------+------------+ + // | A | inner | outer | + // | B | outer | outer | + // +-----+-------------+------------+ + // + // 2. extends_clause is weird. This is a valid class: + // + // class extends Foo; + // end Foo; + // + // What does this even mean? Is this a definition of Foo or a redeclaration of Foo? + // + // 3. Import aliases. Should this be considered to be a declaration of `Frobnicator`? + // + // import Frobnicator = Foo.Bar.Baz; + // + + private async onDeclaration(params: LSP.DeclarationParams): Promise { + logger.debug("onDeclaration"); + + const locationLink = this.analyzer.findDeclaration(params.textDocument.uri, params.position); + if (locationLink == null) { + return []; + } - private async analyzeDocument(document: TextDocument) { - const diagnostics = this.analyzer.analyze(document); + return [locationLink]; + } + + private async onDefinition(params: LSP.DefinitionParams): Promise { + logger.debug("onDefinition"); + + const locationLink = this.analyzer.findDeclaration(params.textDocument.uri, params.position); + if (locationLink == null) { + return []; + } + + return [locationLink]; } - /** - * Provide symbols defined in document. - * - * @param params Unused. - * @returns Symbol information. - */ private onDocumentSymbol(params: LSP.DocumentSymbolParams): LSP.SymbolInformation[] { + logger.debug(`onDocumentSymbol`); // TODO: ideally this should return LSP.DocumentSymbol[] instead of LSP.SymbolInformation[] // which is a hierarchy of symbols. // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol - logger.debug(`onDocumentSymbol`); return this.analyzer.getDeclarationsForUri(params.textDocument.uri); } - } // Create a connection for the server, using Node's IPC as a transport. // Also include all preview / proposed LSP features. const connection = LSP.createConnection(LSP.ProposedFeatures.all); -connection.onInitialize( - async (params: LSP.InitializeParams): Promise => { - const server = await ModelicaServer.initialize(connection, params); - server.register(connection); - return { - capabilities: server.capabilities(), - }; - } -); +connection.onInitialize(async (params) => { + const server = await ModelicaServer.initialize(connection, params); + await server.register(connection); + return { + capabilities: server.capabilities, + }; +}); // Listen on the connection connection.listen(); diff --git a/server/src/util/declarations.ts b/server/src/util/declarations.ts index fbcface..3249de9 100644 --- a/server/src/util/declarations.ts +++ b/server/src/util/declarations.ts @@ -42,18 +42,12 @@ import * as LSP from 'vscode-languageserver/node'; import * as Parser from 'web-tree-sitter'; import * as TreeSitterUtil from './tree-sitter'; -import { logger } from './logger'; const isEmpty = (data: string): boolean => typeof data === "string" && data.trim().length == 0; export type GlobalDeclarations = { [word: string]: LSP.SymbolInformation } export type Declarations = { [word: string]: LSP.SymbolInformation[] } -const GLOBAL_DECLARATION_LEAF_NODE_TYPES = new Set([ - 'if_statement', - 'function_definition', -]); - /** * Returns all declarations (functions or variables) from a given tree. * @@ -61,7 +55,7 @@ const GLOBAL_DECLARATION_LEAF_NODE_TYPES = new Set([ * @param uri The document's uri. * @returns Symbol information for all declarations. */ -export function getAllDeclarationsInTree(tree: Parser.Tree, uri: string): LSP.SymbolInformation[] { +export function getAllDeclarationsInTree(tree: Parser.Tree, uri: LSP.DocumentUri): LSP.SymbolInformation[] { const symbols: LSP.SymbolInformation[] = []; TreeSitterUtil.forEach(tree.rootNode, (node) => { @@ -81,7 +75,7 @@ export function getAllDeclarationsInTree(tree: Parser.Tree, uri: string): LSP.Sy * @param uri The document's uri. * @returns Symbol information from node. */ -export function nodeToSymbolInformation(node: Parser.SyntaxNode, uri: string): LSP.SymbolInformation | null { +export function nodeToSymbolInformation(node: Parser.SyntaxNode, uri: LSP.DocumentUri): LSP.SymbolInformation | null { const named = node.firstNamedChild; if (named === null) { @@ -115,7 +109,7 @@ export function nodeToSymbolInformation(node: Parser.SyntaxNode, uri: string): L * @param uri The associated URI for this document. * @returns LSP symbol information for definition. */ -function getDeclarationSymbolFromNode(node: Parser.SyntaxNode, uri: string): LSP.SymbolInformation | null { +function getDeclarationSymbolFromNode(node: Parser.SyntaxNode, uri: LSP.DocumentUri): LSP.SymbolInformation | null { if (TreeSitterUtil.isDefinition(node)) { return nodeToSymbolInformation(node, uri); } diff --git a/server/src/util/index.ts b/server/src/util/index.ts new file mode 100644 index 0000000..0017b94 --- /dev/null +++ b/server/src/util/index.ts @@ -0,0 +1,14 @@ +import * as url from "node:url"; +import * as LSP from "vscode-languageserver"; + + +export const uriToPath = url.fileURLToPath; + +export function pathToUri(filePath: string): LSP.URI { + const uri = url.pathToFileURL(filePath).href; + + // Note: LSP sends us file uris containing '%3A' instead of ':', but the + // node pathToFileURL uses ':' anyways. We manually fix this here. This is a + // bit hacky but it works. + return uri.slice(0, 5) + uri.slice(5).replace(":", "%3A"); +} diff --git a/server/src/util/logger.ts b/server/src/util/logger.ts index be3df8d..068a3fb 100644 --- a/server/src/util/logger.ts +++ b/server/src/util/logger.ts @@ -165,4 +165,6 @@ export function getLogLevelFromEnvironment(): LSP.MessageType { } return LOG_LEVELS_TO_MESSAGE_TYPES[DEFAULT_LOG_LEVEL]; -} \ No newline at end of file +} + +export default logger; diff --git a/server/src/util/tree-sitter.ts b/server/src/util/tree-sitter.ts index 90a37b2..29f7ab0 100644 --- a/server/src/util/tree-sitter.ts +++ b/server/src/util/tree-sitter.ts @@ -39,10 +39,14 @@ * ----------------------------------------------------------------------------- */ -import * as LSP from 'vscode-languageserver/node'; -import { SyntaxNode } from 'web-tree-sitter'; +import * as LSP from "vscode-languageserver/node"; +import Parser from "web-tree-sitter"; +import { SyntaxNode } from "web-tree-sitter"; -import { logger } from './logger'; +import { logger } from "./logger"; +import { TextDocument } from "vscode-languageserver-textdocument"; + +type MaybePromise = T | Promise; /** * Recursively iterate over all nodes in a tree. @@ -50,10 +54,32 @@ import { logger } from './logger'; * @param node The node to start iterating from * @param callback The callback to call for each node. Return false to stop following children. */ -export function forEach(node: SyntaxNode, callback: (n: SyntaxNode) => void | boolean) { - const followChildren = callback(node) !== false; - if (followChildren && node.children.length) { - node.children.forEach((n) => forEach(n, callback)); +export function forEach(start: SyntaxNode, callback: (n: SyntaxNode) => void | boolean): void; +export function forEach( + start: SyntaxNode, + callback: (n: SyntaxNode) => Promise, +): Promise; +export function forEach( + start: SyntaxNode, + callback: (n: SyntaxNode) => MaybePromise, +): MaybePromise; +export function forEach( + start: SyntaxNode, + callback: (n: SyntaxNode) => MaybePromise, +): MaybePromise { + const callbackResult = callback(start); + if (typeof callbackResult === "object") { + return callbackResult.then(async (callbackResult) => { + const followChildren = callbackResult !== false; + if (followChildren && start.children.length) { + await Promise.all(start.children.map((n) => forEach(n, callback))); + } + }); + } + + const followChildren = callbackResult !== false; + if (followChildren && start.children.length) { + start.children.forEach((n) => forEach(n, callback)); } } @@ -65,15 +91,17 @@ export function forEach(node: SyntaxNode, callback: (n: SyntaxNode) => void | bo * @param start The node to start iterating from * @param callback Callback returning true if node is searched node. */ -export function findFirst(start: SyntaxNode, callback: (n: SyntaxNode) => boolean): SyntaxNode | null { - +export function findFirst( + start: SyntaxNode, + callback: (n: SyntaxNode) => boolean, +): SyntaxNode | null { const cursor = start.walk(); let reachedRoot = false; let retracing = false; while (!reachedRoot) { const node = cursor.currentNode(); - if (callback(node) === true ) { + if (callback(node) === true) { return node; } @@ -87,14 +115,14 @@ export function findFirst(start: SyntaxNode, callback: (n: SyntaxNode) => boolea retracing = true; while (retracing) { - if (!cursor.gotoParent()) { - retracing = false; - reachedRoot = true; - } + if (!cursor.gotoParent()) { + retracing = false; + reachedRoot = true; + } - if (cursor.gotoNextSibling()) { + if (cursor.gotoNextSibling()) { retracing = false; - } + } } } @@ -118,7 +146,42 @@ export function range(n: SyntaxNode): LSP.Range { */ export function isDefinition(n: SyntaxNode): boolean { switch (n.type) { - case 'class_definition': + case "class_definition": + return true; + default: + return false; + } +} + +/** + * Tell if a node is a variable declaration. + * + * @param n Node of tree + * @returns `true` if node is a variable declaration, `false` otherwise. + */ +export function isVariableDeclaration(n: SyntaxNode): boolean { + switch (n.type) { + case "component_clause": + case "component_redeclaration": + return true; + case "named_element": + return n.childForFieldName("classDefinition") == null; + default: + return false; + } +} + +/** + * Tell if a node is an element list. + * + * @param n Node of tree + * @returns `true` if node is an element list, `false` otherwise. + */ +export function isElementList(n: SyntaxNode): boolean { + switch (n.type) { + case "element_list": + case "public_element_list": + case "protected_element_list": return true; default: return false; @@ -145,11 +208,180 @@ export function findParent( * @param start Syntax tree node. */ export function getIdentifier(start: SyntaxNode): string | undefined { - - const node = findFirst(start, (n: SyntaxNode) => n.type == 'IDENT'); + const node = findFirst(start, (n: SyntaxNode) => n.type == "IDENT"); return node?.text; } +/** + * Returns the identifier(s) declared by the given node, or `[]` if no + * identifiers are declared. + * + * Note: this does not return any identifiers that are declared "inside" of the + * node. For instance, calling `getDeclaredIdentifiers` on a class_definition + * will only return the name of the class. + * + * @param node The node to check. Must be a declaration. + * @returns The identifiers. + */ +export function getDeclaredIdentifiers(node: SyntaxNode): string[] { + if (node == null) { + throw new Error("getDeclaredIdentifiers called with null/undefined node"); + } + + // TODO: does this support all desired node types? Are we considering too many nodes? + switch (node.type) { + case "declaration": + case "derivative_class_specifier": + case "enumeration_class_specifier": + case "extends_class_specifier": + case "long_class_specifier": + case "short_class_specifier": + case "enumeration_literal": + case "for_index": + return [node.childForFieldName("identifier")!.text]; + case "stored_definitions": + case "component_list": + case "enum_list": + case "element_list": + case "public_element_list": + case "protected_element_list": + case "for_indices": + return node.namedChildren.flatMap(getDeclaredIdentifiers); + case "component_clause": + return getDeclaredIdentifiers(node.childForFieldName("componentDeclarations")!); + case "component_declaration": + return getDeclaredIdentifiers(node.childForFieldName("declaration")!); + case "component_redeclaration": + return getDeclaredIdentifiers(node.childForFieldName("componentClause")!); + case "stored_definition": + return getDeclaredIdentifiers(node.childForFieldName("classDefinition")!); + case "class_definition": + return getDeclaredIdentifiers(node.childForFieldName("classSpecifier")!); + case "for_equation": + case "for_statement": + return getDeclaredIdentifiers(node.childForFieldName("indices")!); + case "named_element": { + const definition = + node.childForFieldName("classDefinition") ?? node.childForFieldName("componentClause")!; + return getDeclaredIdentifiers(definition); + } + default: + return []; + } +} + +export function hasIdentifier(node: SyntaxNode | null, identifier: string): boolean { + if (!node) { + return false; + } + + return getDeclaredIdentifiers(node).includes(identifier); +} + +export interface TypeSpecifier { + isGlobal: boolean; + symbols: string[]; + symbolNodes: SyntaxNode[]; +} + +export function getTypeSpecifier(node: SyntaxNode): TypeSpecifier { + switch (node.type) { + case "type_specifier": { + const isGlobal = node.childForFieldName("global") !== null; + const name = node.childForFieldName("name")!; + const symbolNodes = getNameIdentifiers(name); + return { + isGlobal, + symbols: symbolNodes.map((id) => id.text), + symbolNodes, + }; + } + case "name": { + const symbolNodes = getNameIdentifiers(node); + return { + isGlobal: false, + symbols: symbolNodes.map((id) => id.text), + symbolNodes, + }; + } + case "IDENT": + return { + isGlobal: false, + symbols: [node.text], + symbolNodes: [node], + }; + default: { + const typeSpecifier = findFirst(node, (child) => child.type === "type_specifier"); + if (typeSpecifier) { + return getTypeSpecifier(typeSpecifier); + } + + const name = findFirst(node, (child) => child.type === "name"); + if (name) { + return getTypeSpecifier(name); + } + + throw new Error("Syntax node does not contain a type_specifier or name"); + } + } +} + +// TODO: this does not handle indexing arrays +export interface ComponentReference { + isGlobal: boolean; + components: string[]; + componentNodes: SyntaxNode[]; +} + +export function getComponentReference(node: SyntaxNode): ComponentReference { + switch (node.type) { + case "component_reference": { + const isGlobal = node.childForFieldName("global") !== null; + const componentNodes = getNameIdentifiers(node); + + return { + isGlobal, + components: componentNodes.map((id) => id.text), + componentNodes, + }; + } + case "IDENT": + return { + isGlobal: false, + components: [node.text], + componentNodes: [node], + }; + default: { + const componentRef = findFirst(node, (child) => child.type === "component_reference"); + if (componentRef) { + return getComponentReference(componentRef); + } + + throw new Error("Syntax node does not contain a component_reference"); + } + } +} + +/** + * Converts a name `SyntaxNode` into an array of the `IDENT`s in that node. + */ +function getNameIdentifiers(nameNode: SyntaxNode): Parser.SyntaxNode[] { + if (nameNode.type !== "name" && nameNode.type !== "component_reference") { + throw new Error( + `Expected a 'name' or 'component_reference' node; got '${nameNode.type}' (${nameNode.text})`, + ); + } + + const identNode = nameNode.childForFieldName("identifier")!; + const qualifierNode = nameNode.childForFieldName("qualifier"); + if (qualifierNode) { + const qualifier = getNameIdentifiers(qualifierNode); + return [...qualifier, identNode]; + } else { + return [identNode]; + } +} + /** * Get class prefixes from `class_definition` node. * @@ -157,15 +389,48 @@ export function getIdentifier(start: SyntaxNode): string | undefined { * @returns String with class prefixes or `null` if no `class_prefixes` can be found. */ export function getClassPrefixes(node: SyntaxNode): string | null { - - if (node.type !== 'class_definition') { + if (node.type !== "class_definition") { return null; } - const classPrefixNode = node.childForFieldName('classPrefixes'); - if (classPrefixNode == null || classPrefixNode.type !== 'class_prefixes') { + const classPrefixNode = node.childForFieldName("classPrefixes"); + if (classPrefixNode == null || classPrefixNode.type !== "class_prefixes") { return null; } return classPrefixNode.text; } + +export function positionToPoint(position: LSP.Position): Parser.Point { + return { row: position.line, column: position.character }; +} + +export function pointToPosition(point: Parser.Point): LSP.Position { + return { line: point.row, character: point.column }; +} + +export function createLocationLink( + document: TextDocument, + node: Parser.SyntaxNode, +): LSP.LocationLink; +export function createLocationLink( + documentUri: LSP.DocumentUri, + node: Parser.SyntaxNode, +): LSP.LocationLink; +export function createLocationLink( + document: TextDocument | LSP.DocumentUri, + node: Parser.SyntaxNode, +): LSP.LocationLink { + // TODO: properly set targetSelectionRange (e.g. the name of a function or variable). + return { + targetUri: typeof document === "string" ? document : document.uri, + targetRange: { + start: pointToPosition(node.startPosition), + end: pointToPosition(node.endPosition), + }, + targetSelectionRange: { + start: pointToPosition(node.startPosition), + end: pointToPosition(node.endPosition), + }, + }; +}