diff --git a/.eslintrc.js b/.eslintrc.js index 10793c5..8f2afe6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -12,7 +12,8 @@ module.exports = { ], rules: { 'semi': [2, "always"], - '@typescript-eslint/no-unused-vars': 0, + '@typescript-eslint/no-unused-vars': ["warn"], + '@typescript-eslint/no-unused-expressions': ["warn"], '@typescript-eslint/no-explicit-any': 0, '@typescript-eslint/explicit-module-boundary-types': 0, '@typescript-eslint/no-non-null-assertion': 0, diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d88a42d..0e91e93 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,6 +34,9 @@ jobs: - name: Build package run: npm run esbuild + - name: ESLint + run: npm run lint + - name: Test language server run: npm run test:server diff --git a/README.md b/README.md index 07debb0..31e3940 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ features: ![Outline](images/outline_demo.png) + - Hover provider for declared symbols. + + ![Hover](images/hover_demo.png) + ## Installation ### Via Marketplace diff --git a/client/src/extension.ts b/client/src/extension.ts index 4348345..fddba21 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -43,7 +43,6 @@ import { TransportKind, } from 'vscode-languageclient/node'; import { getFileExtension, getLanguage } from './getLanguage'; -import { fstat } from 'fs'; let client: LanguageClient; diff --git a/client/src/test/onHover.test.ts b/client/src/test/onHover.test.ts new file mode 100644 index 0000000..93089a4 --- /dev/null +++ b/client/src/test/onHover.test.ts @@ -0,0 +1,105 @@ +/* + * 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 fs from 'fs'; +import * as vscode from 'vscode'; +import * as assert from 'assert'; +import { getDocUri, getDocPath, activate } from './helper'; + +suite('onHover information', async () => { + test('Step', async () => { + const docUri = getDocUri('step.mo'); + const position = new vscode.Position(19, 25); + const content = new vscode.MarkdownString( + fs.readFileSync(getDocPath('step.md'), 'utf-8')); + const expectedHoverInstances: vscode.Hover[] = [ + new vscode.Hover(content) + ]; + + await testOnHover(docUri, position, expectedHoverInstances); + }); + + test('velocityOfSound_ph', async () => { + const docUri = getDocUri('velocityOfSound_ph.mo'); + const position = new vscode.Position(0, 20); + const content = new vscode.MarkdownString( + fs.readFileSync(getDocPath('velocityOfSound_ph.md'), 'utf-8')); + const expectedHoverInstances: vscode.Hover[] = [ + new vscode.Hover(content) + ]; + + await testOnHover(docUri, position, expectedHoverInstances); + }); +}); + +async function testOnHover( + uri: vscode.Uri, + position: vscode.Position, + expectedHoverInstances: vscode.Hover[] +) { + await activate(uri); + + // Execute `vscode.executeHoverProvider` to execute all hover providers + const actualHoverInstances = await vscode.commands.executeCommand("vscode.executeHoverProvider", uri, position); + + assertHoverInstancesEqual(expectedHoverInstances, actualHoverInstances); +} + +function assertHoverInstancesEqual(expected: vscode.Hover[], actual: vscode.Hover[]) { + assert.strictEqual(expected.length, actual.length, 'Array lengths do not match.'); + + for (let i = 0; i < expected.length; i++) { + const expectedHover = expected[i]; + const actualHover = actual[i]; + + let expectedContent = ""; + for (let j = 0; j < expectedHover.contents.length; j++) { + const content = expectedHover.contents[j]; + if (content instanceof vscode.MarkdownString) { + expectedContent += content.value; + } + } + + let actualContent = ""; + for (let j = 0; j < actualHover.contents.length; j++) { + const content = actualHover.contents[j]; + if (content instanceof vscode.MarkdownString) { + actualContent += content.value; + } + } + + assert.strictEqual(actualContent.trim(), expectedContent.trim(), `Content does not match expected content.`); + } +} diff --git a/client/src/test/symbolinformation.test.ts b/client/src/test/symbolinformation.test.ts index c2ddfb6..8a7adfd 100644 --- a/client/src/test/symbolinformation.test.ts +++ b/client/src/test/symbolinformation.test.ts @@ -76,31 +76,9 @@ async function testSymbolInformation( docUri, ); - //printDocumentSymbols(actualSymbolInformation); assertDocumentSymbolsEqual(expectedDocumentSymbols, actualSymbolInformation); } -function printDocumentSymbols(documentSymbols: vscode.DocumentSymbol[]) { - documentSymbols.forEach((symbol, index) => { - console.log(`Document Symbol ${index + 1}:`); - console.log(`Name: ${symbol.name}`); - console.log(`Kind: ${vscode.SymbolKind[symbol.kind]}`); - console.log( - `Range: ${symbol.range.start.line}:${symbol.range.start.character}, ${symbol.range.end.line}:${symbol.range.end.character}`, - ); - console.log( - `SelectionRange: ${symbol.selectionRange.start.line}:${symbol.selectionRange.start.character}, ${symbol.selectionRange.end.line}:${symbol.selectionRange.end.character}`, - ); - console.log('Children:'); - - if (symbol.children && symbol.children.length > 0) { - printDocumentSymbols(symbol.children); - } - - console.log('---'); - }); -} - function assertDocumentSymbolsEqual( expected: vscode.DocumentSymbol[], actual: vscode.DocumentSymbol[], diff --git a/client/testFixture/MyLibrary/Examples/M.mo b/client/testFixture/MyLibrary/Examples/M.mo new file mode 100644 index 0000000..bc51403 --- /dev/null +++ b/client/testFixture/MyLibrary/Examples/M.mo @@ -0,0 +1,7 @@ +within MyLibrary.Examples; + +model M "MWE Modelica Model" + Real x(start = 1.0, fixed = true); +equation + der(x) = -0.5*x; +end M; diff --git a/client/testFixture/MyLibrary/Examples/package.mo b/client/testFixture/MyLibrary/Examples/package.mo new file mode 100644 index 0000000..e938914 --- /dev/null +++ b/client/testFixture/MyLibrary/Examples/package.mo @@ -0,0 +1,4 @@ +within MyLibrary; + +package Examples +end Examples; diff --git a/client/testFixture/MyLibrary/Examples/package.order b/client/testFixture/MyLibrary/Examples/package.order new file mode 100644 index 0000000..ab77689 --- /dev/null +++ b/client/testFixture/MyLibrary/Examples/package.order @@ -0,0 +1 @@ +M diff --git a/client/testFixture/MyLibrary/package.mo b/client/testFixture/MyLibrary/package.mo new file mode 100644 index 0000000..7b8ef37 --- /dev/null +++ b/client/testFixture/MyLibrary/package.mo @@ -0,0 +1,2 @@ +package MyLibrary "My Modelica Library" +end MyLibrary; diff --git a/client/testFixture/MyLibrary/package.order b/client/testFixture/MyLibrary/package.order new file mode 100644 index 0000000..ad6b7fb --- /dev/null +++ b/client/testFixture/MyLibrary/package.order @@ -0,0 +1 @@ +Examples diff --git a/client/testFixture/step.md b/client/testFixture/step.md new file mode 100644 index 0000000..90f3cb9 --- /dev/null +++ b/client/testFixture/step.md @@ -0,0 +1,20 @@ +```modelica +class Modelica_Blocks_Sources_Step "Generate step signal of type Real" +``` +--- + +**Parameter Inputs** +```modelica +parameter input Real height = 1.0 "Height of step"; +``` +**Outputs** +```modelica +output Real y "Connector of Real output signal"; +``` + +**Parameter** +```modelica +parameter input Real height = 1.0 "Height of step"; +parameter Real offset = 0.0 "Offset of output signal y"; +parameter Real startTime(quantity = "Time", unit = "s") = 0.0 "Output y = offset for time < startTime"; +``` diff --git a/client/testFixture/step.mo b/client/testFixture/step.mo new file mode 100644 index 0000000..04262d1 --- /dev/null +++ b/client/testFixture/step.mo @@ -0,0 +1,20 @@ +class Modelica_Blocks_Sources_Step "Generate step signal of type Real" + parameter input Real height = 1.0 "Height of step"; + output Real y "Connector of Real output signal"; + parameter Real offset = 0.0 "Offset of output signal y"; + parameter Real startTime(quantity = "Time", unit = "s") = 0.0 "Output y = offset for time < startTime"; +equation + y = offset + (if time < startTime then 0.0 else height); + annotation ( + Documentation(info=" +

+The Real output y is a step signal: +

+ +

+\"Step.png\" +

+ +")); +end Modelica_Blocks_Sources_Step; diff --git a/client/testFixture/velocityOfSound_ph.md b/client/testFixture/velocityOfSound_ph.md new file mode 100644 index 0000000..5bfbcf4 --- /dev/null +++ b/client/testFixture/velocityOfSound_ph.md @@ -0,0 +1,16 @@ +```modelica +function velocityOfSound_ph +``` +--- +**Inputs** +```modelica +input SI.Pressure p "Pressure"; +input SI.SpecificEnthalpy h "Specific enthalpy"; +input Integer phase = 0 "2 for two-phase, 1 for one-phase, 0 if not known"; +input Integer region = 0 "If 0, region is unknown, otherwise known and this input"; +``` + +**Outputs** +```modelica +output SI.Velocity v_sound "Speed of sound"; +``` diff --git a/client/testFixture/velocityOfSound_ph.mo b/client/testFixture/velocityOfSound_ph.mo new file mode 100644 index 0000000..2bd5f0c --- /dev/null +++ b/client/testFixture/velocityOfSound_ph.mo @@ -0,0 +1,11 @@ +function velocityOfSound_ph + extends Modelica.Icons.Function; + input SI.Pressure p "Pressure"; + input SI.SpecificEnthalpy h "Specific enthalpy"; + input Integer phase = 0 "2 for two-phase, 1 for one-phase, 0 if not known"; + input Integer region = 0 "If 0, region is unknown, otherwise known and this input"; + output SI.Velocity v_sound "Speed of sound"; +algorithm + v_sound := velocityOfSound_props_ph(p, h, waterBaseProp_ph(p, h, phase, region)); + annotation(Inline = true); +end velocityOfSound_ph; diff --git a/images/hover_demo.png b/images/hover_demo.png new file mode 100644 index 0000000..8151876 Binary files /dev/null and b/images/hover_demo.png differ diff --git a/server/src/analyzer.ts b/server/src/analyzer.ts index a5d5ec2..d771911 100644 --- a/server/src/analyzer.ts +++ b/server/src/analyzer.ts @@ -41,7 +41,6 @@ import * as LSP from 'vscode-languageserver/node'; import { TextDocument } from 'vscode-languageserver-textdocument'; - import Parser = require('web-tree-sitter'); import { getAllDeclarationsInTree } from './util/declarations'; @@ -98,4 +97,151 @@ export default class Analyzer { return getAllDeclarationsInTree(tree, uri); } + + /** + * Get all reachable definitions matching identifier. + * + * TODO: All available analyzed documents are searched. Filter for reachable + * files and use scope of identifier. + * + * @param uri Text document. + * @param position Position of `identifier` in text document. + * @param identifier Identifier name. + * @returns Array of symbol information for `identifier. + */ + public getReachableDefinitions( + uri: string, + position: LSP.Position, + identifier: string): LSP.SymbolInformation[] { + + const declarations:LSP.SymbolInformation[] = []; + + // Find all declarations matching identifier. + for (const availableUri of Object.keys(this.#uriToAnalyzedDocument)) { + // TODO: Filter reachable uri, e.g. because of an include + const decl = this.#uriToAnalyzedDocument[availableUri]?.declarations; + if (decl) { + for (const d of decl) { + if (d.name === identifier) { + declarations.push(d); + } + } + } + } + + // TODO: Filter reachable declarations from scope. + return declarations; + } + + /** + * Find a block of comments above a line position + */ + public commentsAbove(uri: string, line: number): string | null { + const doc = this.#uriToAnalyzedDocument[uri]?.document; + if (!doc) { + return null; + } + + let commentBlock = []; + let inBlockComment = false; + + // start from the line above + let commentBlockIndex = line - 1; + + while (commentBlockIndex >= 0) { + let currentLineText = doc.getText({ + start: { line: commentBlockIndex, character: 0 }, + end: { line: commentBlockIndex + 1, character: 0 }, + }).trim(); + + if (inBlockComment) { + if (currentLineText.startsWith('/*')) { + inBlockComment = false; + // Remove the /* from the start + currentLineText = currentLineText.substring(2).trim(); + } else { + // Remove leading * from lines within the block comment + currentLineText = currentLineText.replace(/^\*\s?/, '').trim(); + } + if (currentLineText) { // Don't add empty lines + commentBlock.push(currentLineText); + } + } else { + if (currentLineText.startsWith('//')) { + // Strip the // and add to block + commentBlock.push(currentLineText.substring(2).trim()); + } else if (currentLineText.endsWith('*/')) { + inBlockComment = true; + // Remove the */ from the end + currentLineText = currentLineText.substring(0, currentLineText.length - 2).trim(); + if (currentLineText) { // Don't add empty lines + commentBlock.push(currentLineText); + } + } else { + break; // Stop if the current line is not part of a comment + } + } + + commentBlockIndex -= 1; + } + + if (commentBlock.length) { + commentBlock = [...commentBlock.reverse()]; + return commentBlock.join('\n\n'); + } + + return null; + } + + /** + * Return IDENT node from given text position. + * + * Check if a node of type identifier exists at given position and return it. + * + * @param params Text document position. + * @returns Identifier syntax node. + */ + public NodeFromTextPosition( + params: LSP.TextDocumentPositionParams, + ): Parser.SyntaxNode | null { + + const node = this.nodeAtPoint( + params.textDocument.uri, + params.position.line, + params.position.character); + + if (!node || node.childCount > 0 || node.text.trim() === '') { + return null; + } + + // Filter for identifier + if (node.type !== "IDENT") { + return null; + } + + return node; + } + + /** + * Return abstract syntax tree node representing text position. + * + * @param uri + * @param line + * @param column + * @returns Node matching position. + */ + private nodeAtPoint( + uri: string, + line: number, + column: number, + ): Parser.SyntaxNode | null { + const tree = this.#uriToAnalyzedDocument[uri]?.tree; + + if (!tree?.rootNode) { + // Check for lacking rootNode (due to failed parse?) + return null; + } + + return tree.rootNode.descendantForPosition({ row: line, column }); + } } diff --git a/server/src/server.ts b/server/src/server.ts index f16d085..553572d 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -36,7 +36,7 @@ /* ----------------------------------------------------------------------------- * Taken from bash-language-server and adapted to Modelica language server * https://github.com/bash-lsp/bash-language-server/blob/main/server/src/server.ts - * ----------------------------------------------------------------------------- + * ---------------------------------------------------------------------------- */ import * as LSP from 'vscode-languageserver/node'; @@ -44,6 +44,7 @@ import { TextDocument } from 'vscode-languageserver-textdocument'; import { initializeParser } from './parser'; import Analyzer from './analyzer'; +import { extractHoverInformation } from './util/hoverUtil'; import { logger, setLogConnection, setLogLevel } from './util/logger'; /** @@ -90,7 +91,7 @@ export class ModelicaServer { return { textDocumentSync: LSP.TextDocumentSyncKind.Full, completionProvider: undefined, - hoverProvider: false, + hoverProvider: true, signatureHelpProvider: undefined, documentSymbolProvider: true, colorProvider: false, @@ -98,6 +99,11 @@ export class ModelicaServer { }; } + /** + * Register handlers for the events from the Language Server Protocol + * + * @param connection + */ public register(connection: LSP.Connection): void { let currentDocument: TextDocument | null = null; let initialized = false; @@ -106,17 +112,6 @@ export class ModelicaServer { // for open, change and close text document events this.#documents.listen(this.#connection); - connection.onDocumentSymbol(this.onDocumentSymbol.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); - } - }); - // 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 }) => { @@ -130,24 +125,83 @@ export class ModelicaServer { this.analyzeDocument(document); } }); + + connection.onDocumentSymbol(this.onDocumentSymbol.bind(this)); + connection.onHover(this.onHover.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 analyzeDocument(document: TextDocument) { - const diagnostics = this.#analyzer.analyze(document); + this.#analyzer.analyze(document); } + // ============================== + // Language server event handlers + // ============================== + /** * Provide symbols defined in document. * - * @param params Unused. - * @returns Symbol information. + * @param symbolParams Document symbols of given text document. + * @returns Symbol information. */ - private onDocumentSymbol(params: LSP.DocumentSymbolParams): LSP.SymbolInformation[] { + private onDocumentSymbol(symbolParams: LSP.DocumentSymbolParams): LSP.SymbolInformation[] { // 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); + return this.#analyzer.getDeclarationsForUri(symbolParams.textDocument.uri); + } + + /** + * Provide hover information at given text document position. + * + * @param position Text document position. + * @returns Hover information. + */ + private async onHover( + position: LSP.TextDocumentPositionParams + ): Promise { + logger.debug('onHover'); + + const node = this.#analyzer.NodeFromTextPosition(position); + if (node === null) { + return null; + } + + const identifier = node.text.trim(); + const symbolsMatchingWord = this.#analyzer.getReachableDefinitions( + position.textDocument.uri, + position.position, + identifier); + logger.debug('symbolsMatchingWord: ', symbolsMatchingWord); + if (symbolsMatchingWord.length == 0) { + return null; + } + + // TODO: Get node defining symbol and extract hover information of that one. + const hoverInfo = extractHoverInformation(node); + if (hoverInfo == null) { + return null; + } + logger.debug(hoverInfo); + + const markdown : LSP.MarkupContent = { + kind: LSP.MarkupKind.Markdown, + value: hoverInfo + }; + + return { + contents: markdown + } as LSP.Hover; } } diff --git a/server/src/test/server.test.ts b/server/src/test/server.test.ts index 8c982d2..8b97d1b 100644 --- a/server/src/test/server.test.ts +++ b/server/src/test/server.test.ts @@ -33,9 +33,7 @@ * */ -import * as Mocha from 'mocha'; import * as assert from 'assert'; -import * as Parser from 'web-tree-sitter'; import { initializeParser } from '../parser'; @@ -51,7 +49,7 @@ const parsedModelicaTestString = describe('Modelica tree-sitter parser', () => { it('Initialize parser', async () => { - const parser = await initializeParser(); + await initializeParser(); }); it('Parse string', async () => { diff --git a/server/src/util/array.ts b/server/src/util/array.ts new file mode 100644 index 0000000..360a997 --- /dev/null +++ b/server/src/util/array.ts @@ -0,0 +1,14 @@ +/** + * Flatten a 2-dimensional array into a 1-dimensional one. + */ +export function flattenArray(nestedArray: T[][]): T[] { + return nestedArray.reduce((acc, array) => [...acc, ...array], []); +} + +/** + * Remove all duplicates from the list. + * Doesn't preserve ordering. + */ +export function uniq(a: A[]): A[] { + return Array.from(new Set(a)); +} diff --git a/server/src/util/declarations.ts b/server/src/util/declarations.ts index eb60c68..549e736 100644 --- a/server/src/util/declarations.ts +++ b/server/src/util/declarations.ts @@ -42,14 +42,54 @@ 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']); +export function getLocalDeclarations({ + node, + uri, +}: { + node: Parser.SyntaxNode | null + uri: string +}): Declarations { + const declarations: Declarations = {}; + + // Bottom up traversal to capture all local and scoped declarations + const walk = (node: Parser.SyntaxNode | null) => { + if (node) { + for (const childNode of node.children) { + let symbol: LSP.SymbolInformation | null = null; + + // local variables + if (childNode.type === 'component_reference') { + const identifierNode = childNode.children.filter( + (child) => child.type === 'IDENT', + )[0]; + if (identifierNode) { + symbol = nodeToSymbolInformation(identifierNode, uri); + } + } else { + symbol = getDeclarationSymbolFromNode(childNode, uri); + } + + if (symbol) { + if (!declarations[symbol.name]) { + declarations[symbol.name] = []; + } + declarations[symbol.name].push(symbol); + } + } + + walk(node.parent); + } + }; + walk(node); + + return declarations; +} /** * Returns all declarations (functions or variables) from a given tree. diff --git a/server/src/util/hoverUtil.ts b/server/src/util/hoverUtil.ts new file mode 100644 index 0000000..e80cd73 --- /dev/null +++ b/server/src/util/hoverUtil.ts @@ -0,0 +1,173 @@ +import { SyntaxNode } from 'web-tree-sitter'; +import * as TreeSitterUtil from './tree-sitter'; +import { logger } from './logger'; + +/** + * Extracts hover information for given node. + * + * Documentation and information for class description, inputs, outputs and + * parameters. + * + * TODO: Modify this function to accept class_definition and + * component_declaration and so on. + * TODO: Use querries instead of checking children. + * + * @param node Syntax Node. + * + * @returns Hover content or null if no information available. + */ +export function extractHoverInformation(node: SyntaxNode): string | null { + // Find the parent class_definition node. + const classDefNode = TreeSitterUtil.findParent(node, n => n.type === 'class_definition'); + if (!classDefNode) { + logger.debug('extractHoverInformation: No class definition found.'); + return null; + } + + // Check if node is the first IDENT child of the class_definition, indicating it's the class name. + const isClassName = classDefNode.namedChildren.some((child) => + child.type === 'long_class_specifier' && + child.firstChild?.type === 'IDENT' && + child.firstChild?.text === node.text); + + if (!isClassName) { + logger.debug('extractHoverInformation: Target node is not the class name identifier.'); + return null; + } + + const classDescription = TreeSitterUtil.getDescriptionString(classDefNode); + const {inputsInfo, outputsInfo, parameterInfo, parameterInputsInfo, parameterOutputsInfo} = extractComponentInformation(classDefNode); + const classDefinition = [ + TreeSitterUtil.getClassPrefixes(classDefNode), + node.text, + classDescription, + ].join(' ').trim(); + + return [ + '```modelica', + classDefinition, + '```', + '---', + inputsInfo, + parameterInputsInfo, + outputsInfo, + parameterOutputsInfo, + parameterInfo + ].join('\n'); +} + +function extractComponentInformation( + classDefNode: SyntaxNode): { + inputsInfo: string | undefined; + outputsInfo: string | undefined; + parameterInfo: string | undefined; + parameterInputsInfo: string | undefined; + parameterOutputsInfo: string | undefined; + } { + + const inputsInfo: string[] = []; + const outputsInfo: string[] = []; + const parameterInfo: string[] = []; + const parameterInputsInfo: string[] = []; + const parameterOutputsInfo: string[] = []; + let inputsString: string | undefined = undefined; + let outputsString: string | undefined = undefined; + let parameterString: string | undefined = undefined; + let parameterInputsString: string | undefined = undefined; + let parameterOutputsString: string | undefined = undefined; + + TreeSitterUtil.forEach(classDefNode, (node) => { + if (node.type === 'component_clause') { + const prefix = TreeSitterUtil.getPrefix(node); + const isParameter = TreeSitterUtil.isParameter(node); + if (prefix !== undefined || isParameter) { + const typeSpecifierNode = node.childForFieldName('typeSpecifier'); + const typeSpecifier = typeSpecifierNode ? typeSpecifierNode.text : "Unknown Type"; + + const componentDeclarationNode = node.childForFieldName('componentDeclarations'); + const declarationNode = componentDeclarationNode?.firstChild?.childForFieldName('declaration'); + const identifier = declarationNode ? declarationNode.text : "Unknown Identifier"; + + // Extracting description from description_string node + const descriptionNode = componentDeclarationNode?.firstChild?.childForFieldName('descriptionString'); + const description = descriptionNode ? descriptionNode.text : ''; + + const info = [ + isParameter ? 'parameter' : undefined, + prefix, + typeSpecifier, + identifier, + description + ].filter( (e) => e !== undefined ).join(' ') + ';'; + + if (prefix === 'input') { + if (isParameter) { + parameterInputsInfo.push(info); + } else { + inputsInfo.push(info); + } + } + if (prefix === 'output') { + if (isParameter) { + parameterOutputsInfo.push(info); + } else { + outputsInfo.push(info); + } + } + if (isParameter) { + parameterInfo.push(info); + } + } + } + return true; + }); + + if (inputsInfo.length > 0) { + inputsString = [ + '**Inputs**', + '```modelica', + inputsInfo.join('\n'), + '```' + ].join('\n'); + } + if (parameterInputsInfo.length > 0) { + parameterInputsString = [ + '**Parameter Inputs**', + '```modelica', + parameterInputsInfo.join('\n'), + '```' + ].join('\n'); + } + if (outputsInfo.length > 0) { + outputsString = [ + '**Outputs**', + '```modelica', + outputsInfo.join('\n'), + '```' + ].join('\n'); + } + if (parameterOutputsInfo.length > 0) { + parameterOutputsString = [ + '**Parameter Outputs**', + '```modelica', + parameterOutputsInfo.join('\n'), + '```' + ].join('\n'); + } + if (parameterInfo.length > 0) { + parameterString = [ + '**Parameter**', + '```modelica', + parameterInfo.join('\n'), + '```' + ].join('\n'); + } + + return { + inputsInfo: inputsString, + outputsInfo: outputsString, + parameterInfo: parameterString, + parameterInputsInfo: parameterInputsString, + parameterOutputsInfo: parameterOutputsString, + }; +} diff --git a/server/src/util/tree-sitter.ts b/server/src/util/tree-sitter.ts index f159ce5..67da965 100644 --- a/server/src/util/tree-sitter.ts +++ b/server/src/util/tree-sitter.ts @@ -42,8 +42,6 @@ import * as LSP from 'vscode-languageserver/node'; import { SyntaxNode } from 'web-tree-sitter'; -import { logger } from './logger'; - /** * Recursively iterate over all nodes in a tree. * @@ -127,6 +125,38 @@ export function isDefinition(n: SyntaxNode): boolean { } } +/** + * Get input/output prefix from node. + * + * @param n Node of tree + * @returns Base prefix or undefined. + */ +export function getPrefix(n: SyntaxNode): string | undefined { + switch (n.type) { + case 'short_class_specifier': + return n.childForFieldName('basePrefix')?.text; + case 'component_clause': + return n.childForFieldName('input')?.text || n.childForFieldName('output')?.text; + default: + return undefined; + } +} + +/** + * Check if node is parameter. + * + * @param n Node of tree + * @returns True if node has parameter keyword. + */ +export function isParameter(n: SyntaxNode): boolean { + switch (n.type) { + case 'component_clause': + return n.childForFieldName('parameter') !== null; + default: + return false; + } +} + export function findParent( start: SyntaxNode, predicate: (n: SyntaxNode) => boolean, @@ -169,3 +199,24 @@ export function getClassPrefixes(node: SyntaxNode): string | null { return classPrefixNode.text; } + +/** + * Get description string. + * + * @param node Syntax node + * @returns Description string of node. + */ +export function getDescriptionString(node: SyntaxNode): string | undefined { + let classNode: SyntaxNode | null; + + switch (node.type) { + case 'class_definition': + classNode = node.childForFieldName('classSpecifier'); + if (classNode !== null) { + return getDescriptionString(classNode); + } + return undefined; + default: + return node.childForFieldName('descriptionString')?.text; + } +}