From dc9281b61f54330b746b5a2983b6d375d9f3a4bb Mon Sep 17 00:00:00 2001 From: MarkusEllyton Date: Fri, 25 Oct 2024 12:08:26 +0200 Subject: [PATCH 1/6] Feature: filter variable completion items by context --- CHANGELOG.md | 4 + TODOS.md | 4 +- package.json | 2 +- src/fmu.ts | 26 +-- src/language-features/completion-items.ts | 55 +++++- test/fmu.unit.test.ts | 103 +++-------- .../completion-items.unit.test.ts | 169 +++++++++++++++--- test/language-features/utils.unit.test.ts | 10 ++ 8 files changed, 255 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e6605a..33b9008 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Changed + +- Variable completion items in cosimulation configuration files are now context-aware, only showing the relevant variables, i.e. either inputs, outputs or parameters. + ## [0.1.2] - 2024-10-03 ### Fixed diff --git a/TODOS.md b/TODOS.md index 2440643..1da5aff 100644 --- a/TODOS.md +++ b/TODOS.md @@ -7,10 +7,10 @@ - [x] Weird inconsistent spacing/tabs - [x] The cosim file is not linted when it is opened or when the extension starts. Meaning when the extension first loads it won't catch errors until the file has been edited. Also, if an FMU is ever deleted, it won't show up as an error in the configuration file. - [x] Remove dangling period in Axios error message. -- [ ] Filter autocompletion items for connections to only show input/output/parameters depending on context. +- [x] Filter autocompletion items for connections to only show input/output/parameters depending on context. - [ ] Setup Actions to build extension package - [ ] Additional testing - increase coverage in unit tests -- [ ] Demo video showing basic functionality of extension. +- [x] Demo video showing basic functionality of extension. - [ ] Documentation - MkDocs, for reference: ## v0.2.0 development diff --git a/package.json b/package.json index 6bc73e6..2a57ebc 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "SEE LICENSE IN LICENSE.md", "description": "Co-simulation in VS Code", "repository": "https://github.com/INTO-CPS-Association/Co-Simulation-Studio", - "version": "0.1.2", + "version": "0.1.3", "icon": "into_cps_logo.png", "engines": { "vscode": "^1.82.0" diff --git a/src/fmu.ts b/src/fmu.ts index 131ea0e..0c4c392 100644 --- a/src/fmu.ts +++ b/src/fmu.ts @@ -6,17 +6,14 @@ import { getLogger } from 'logging' const logger = getLogger() -interface ModelInput { - name: string -} - -interface ModelOutput { +export interface ModelVariable { name: string } export interface FMUModel { - inputs: ModelInput[] - outputs: ModelOutput[] + inputs: ModelVariable[] + outputs: ModelVariable[] + parameters: ModelVariable[] } export interface FMUSource { @@ -97,8 +94,9 @@ export async function extractFMUModelFromPath( } const modelDescriptionObject = zipFile.file('modelDescription.xml') - const modelDescriptionContents = - await modelDescriptionObject?.async('nodebuffer') + const modelDescriptionContents = await modelDescriptionObject?.async( + 'nodebuffer' + ) if (modelDescriptionContents) { return parseXMLModelDescription(modelDescriptionContents) @@ -119,8 +117,9 @@ export function parseXMLModelDescription(source: string | Buffer): FMUModel { throw new Error('Failed to parse XML model description.') } - const inputs: ModelInput[] = [] - const outputs: ModelOutput[] = [] + const inputs: ModelVariable[] = [] + const outputs: ModelVariable[] = [] + const parameters: ModelVariable[] = [] // TODO: update this code to use Zod schemas instead of optional chaining and nullish coalescing const modelVariables = @@ -138,11 +137,16 @@ export function parseXMLModelDescription(source: string | Buffer): FMUModel { outputs.push({ name: mVar['@_name'], }) + } else if (varCausality === 'parameter') { + parameters.push({ + name: mVar['@_name'], + }) } } return { inputs, outputs, + parameters, } } diff --git a/src/language-features/completion-items.ts b/src/language-features/completion-items.ts index d63852b..bfc6314 100644 --- a/src/language-features/completion-items.ts +++ b/src/language-features/completion-items.ts @@ -1,11 +1,12 @@ import * as vscode from 'vscode' -import { getNodePath } from 'jsonc-parser' +import { getNodePath, Node } from 'jsonc-parser' import { CosimulationConfiguration, getFMUIdentifierFromConnectionString, getStringContentRange, isNodeString, } from './utils' +import { ModelVariable } from 'fmu' export class SimulationConfigCompletionItemProvider implements vscode.CompletionItemProvider @@ -85,6 +86,8 @@ export class SimulationConfigCompletionItemProvider ): Promise { const completionNode = cosimConfig.getNodeAtPosition(position) + console.log(completionNode) + if ( !completionNode || !isNodeString(completionNode) || @@ -101,15 +104,38 @@ export class SimulationConfigCompletionItemProvider return [] } - const validVariables = - await cosimConfig.getAllVariablesFromIdentifier(fmuIdentifier) + const fmuModel = await cosimConfig.getFMUModel(fmuIdentifier) + + console.log(fmuModel) + + if (!fmuModel) { + return [] + } + + const completionContext = this.getCompletionContext(completionNode) + + console.log('Completion context', completionContext) + + const completionVariables: ModelVariable[] = [] + + if (completionContext === 'input') { + completionVariables.push(...fmuModel.inputs) + } else if (completionContext === 'output') { + completionVariables.push(...fmuModel.outputs) + } else if (completionContext === 'parameter') { + completionVariables.push(...fmuModel.parameters) + } + + const completionStrings = completionVariables.map( + (variable) => variable.name + ) // Get range of the nearest word following a period const range = cosimConfig .getDocument() .getWordRangeAtPosition(position, /(?<=\.)\w+/) - const suggestions = validVariables.map((variable) => { + const suggestions = completionStrings.map((variable) => { const completionItem = new vscode.CompletionItem( variable, vscode.CompletionItemKind.Property @@ -121,4 +147,25 @@ export class SimulationConfigCompletionItemProvider return suggestions } + + getCompletionContext( + completionNode: Node + ): 'input' | 'output' | 'parameter' | null { + const nodePath = getNodePath(completionNode) + + console.log('Node path:', nodePath) + + if (nodePath.length === 2 && nodePath[0] === 'parameters') { + return 'parameter' + } else if (nodePath.length === 2 && nodePath[0] === 'connections') { + return 'output' + } else if ( + nodePath.length === 3 && + nodePath[0] === 'connections' && + typeof nodePath[2] === 'number' + ) { + return 'input' + } + return null + } } diff --git a/test/fmu.unit.test.ts b/test/fmu.unit.test.ts index 014b710..5206bb7 100644 --- a/test/fmu.unit.test.ts +++ b/test/fmu.unit.test.ts @@ -27,10 +27,33 @@ const dummyModelDescription = ` + + ` +const dummyModel: FMUModel = { + inputs: [ + { + name: 'fk', + }, + ], + outputs: [ + { + name: 'x1', + }, + { + name: 'v1', + }, + ], + parameters: [ + { + name: 'c1', + }, + ], +} + describe('FMU Parsing', () => { afterEach(() => { jest.clearAllMocks() @@ -40,21 +63,7 @@ describe('FMU Parsing', () => { it('parses XML model description correctly', async () => { const result = parseXMLModelDescription(dummyModelDescription) - expect(result).toEqual({ - inputs: [ - { - name: 'fk', - }, - ], - outputs: [ - { - name: 'x1', - }, - { - name: 'v1', - }, - ], - } satisfies FMUModel) + expect(result).toEqual(dummyModel) }) it('throws when parsing invalid XML model description', async () => { @@ -105,21 +114,7 @@ describe('FMU Parsing', () => { const result = await extractFMUModelFromPath(Uri.file('file/path')) - expect(result).toEqual({ - inputs: [ - { - name: 'fk', - }, - ], - outputs: [ - { - name: 'x1', - }, - { - name: 'v1', - }, - ], - } satisfies FMUModel) + expect(result).toEqual(dummyModel) }) }) describe('getFMUModelFromPath', () => { @@ -172,21 +167,7 @@ describe('FMU Parsing', () => { 'file/path' ) - expect(result).toEqual({ - inputs: [ - { - name: 'fk', - }, - ], - outputs: [ - { - name: 'x1', - }, - { - name: 'v1', - }, - ], - } satisfies FMUModel) + expect(result).toEqual(dummyModel) expect(vscode.workspace.fs.stat).toHaveBeenCalledWith( Uri.file('/data/file/path') ) @@ -239,21 +220,7 @@ describe('FMU Parsing', () => { 'file/path' ) - expect(result).toEqual({ - inputs: [ - { - name: 'fk', - }, - ], - outputs: [ - { - name: 'x1', - }, - { - name: 'v1', - }, - ], - } satisfies FMUModel) + expect(result).toEqual(dummyModel) const secondResult = await getFMUModelFromPath( workspaceFolder, @@ -289,21 +256,7 @@ describe('FMU Parsing', () => { 'file/path' ) - expect(result).toEqual({ - inputs: [ - { - name: 'fk', - }, - ], - outputs: [ - { - name: 'x1', - }, - { - name: 'v1', - }, - ], - } satisfies FMUModel) + expect(result).toEqual(dummyModel) ;(vscode.workspace.fs.stat as jest.Mock).mockResolvedValue({ ctime: 1, }) diff --git a/test/language-features/completion-items.unit.test.ts b/test/language-features/completion-items.unit.test.ts index d9cb0ce..aa9a126 100644 --- a/test/language-features/completion-items.unit.test.ts +++ b/test/language-features/completion-items.unit.test.ts @@ -1,35 +1,95 @@ +import { FMUModel } from 'fmu' import { createTextDocument } from 'jest-mock-vscode' import { SimulationConfigCompletionItemProvider } from 'language-features/completion-items' import { CosimulationConfiguration } from 'language-features/utils' -import { Position, Uri } from 'vscode' +import { Position, TextDocument, Uri } from 'vscode' const workspaceUri = Uri.file('/data') -const dummyCosimConfig = ` +const dummyCosimConfigTemplate = ` { "fmus": { "{fmu1}": "${Uri.joinPath(workspaceUri, 'fmu1.fmu').path}", "{fmu2}": "${Uri.joinPath(workspaceUri, 'fmu2.fmu').path}" }, "connections": { - "": [""], - "{fmu1}.fmui1.": [""] + "$": [""], + "{fmu1}.fmui1.$": ["{fmu1}.fmui1.$"] }, + "parameters": { + "{fmu1}.fmui1.$" : 1.0 + } } ` -const dummyConfigDocument = createTextDocument( - Uri.joinPath(workspaceUri, 'custom_cosim.json'), - dummyCosimConfig, - 'json' -) +const dummyModel: FMUModel = { + inputs: [ + { + name: 'fk', + }, + ], + outputs: [ + { + name: 'x1', + }, + { + name: 'v1', + }, + ], + parameters: [ + { + name: 'c1', + }, + ], +} + +function getCompletionPosition( + text: string, + completionChar: string, + offset: number +): Position { + const completionCharCount = text.split(completionChar).length - 1 + + if (completionCharCount - 1 < offset) { + throw Error( + 'Offset exceeds the number of completion characters present in the completion template.' + ) + } + + let cleanedText = text + for (let i = 0; i < offset; i++) { + cleanedText = cleanedText.replace(completionChar, '') + } + + const parts = cleanedText.split(completionChar, 1) + const preCompletionLines = parts[0].split('\n') + const completionLine = preCompletionLines.length - 1 + const completionColumn = preCompletionLines[completionLine].length + + return new Position(completionLine, completionColumn) +} + +function constructCompletionExample( + template: string, + offset: number +): [TextDocument, Position] { + const completionPosition = getCompletionPosition(template, '$', offset) + const cosimConfig = template.replaceAll('$', '') + + const dummyConfigDocument = createTextDocument( + Uri.joinPath(workspaceUri, 'custom_cosim.json'), + cosimConfig, + 'json' + ) + + return [dummyConfigDocument, completionPosition] +} describe('SimulationConfigCompletionItemProvider', () => { let cosimConfig: CosimulationConfiguration let simulationConfigCIP: SimulationConfigCompletionItemProvider beforeEach(() => { - cosimConfig = new CosimulationConfiguration(dummyConfigDocument) simulationConfigCIP = new SimulationConfigCompletionItemProvider() }) @@ -40,7 +100,11 @@ describe('SimulationConfigCompletionItemProvider', () => { describe('getFMUIdentifierCompletionItems', () => { it('should return the correct completion items', async () => { // The position inside the empty string of `dummyCosimConfig`, where the completion was triggered. - const pos = new Position(7, 9) + const [dummyConfigDocument, pos] = constructCompletionExample( + dummyCosimConfigTemplate, + 0 + ) + cosimConfig = new CosimulationConfiguration(dummyConfigDocument) const suggestions = await simulationConfigCIP.getFMUIdentifierCompletionItems( @@ -56,22 +120,77 @@ describe('SimulationConfigCompletionItemProvider', () => { }) describe('getFMUVariableCompletionItems', () => { - it('should return the correct completion items', async () => { - // The position inside the connection string right after the final period of `dummyCosimConfig`, where the completion was triggered. - const pos = new Position(8, 22) - const getAllVariablesFromIdentifierSpy = jest.spyOn( - cosimConfig, - 'getAllVariablesFromIdentifier' + it('should return the correct output completion items', async () => { + // The position inside the connection string right after the period, where the completion was triggered. + const [dummyConfigDocument, pos] = constructCompletionExample( + dummyCosimConfigTemplate, + 1 ) - getAllVariablesFromIdentifierSpy.mockImplementation( - async (identifier: string) => { - if (identifier === '{fmu1}') { - return ['v1', 'v2'] - } + cosimConfig = new CosimulationConfiguration(dummyConfigDocument) + const getFMUModelSpy = jest.spyOn(cosimConfig, 'getFMUModel') + getFMUModelSpy.mockImplementation(async (identifier: string) => { + if (identifier === '{fmu1}') { + return dummyModel + } + + return undefined + }) + + const suggestions = + await simulationConfigCIP.getFMUVariableCompletionItems( + cosimConfig, + pos + ) - return [] + expect(suggestions).toHaveLength(2) + + const suggestionLabels = suggestions.map((sug) => sug.label) + expect(suggestionLabels).toEqual(['x1', 'v1']) + }) + + it('should return the correct input completion items', async () => { + // The position inside the string in the inputs array of connections right after the period, where the completion was triggered. + const [dummyConfigDocument, pos] = constructCompletionExample( + dummyCosimConfigTemplate, + 2 + ) + cosimConfig = new CosimulationConfiguration(dummyConfigDocument) + const getFMUModelSpy = jest.spyOn(cosimConfig, 'getFMUModel') + getFMUModelSpy.mockImplementation(async (identifier: string) => { + if (identifier === '{fmu1}') { + return dummyModel } + + return undefined + }) + + const suggestions = + await simulationConfigCIP.getFMUVariableCompletionItems( + cosimConfig, + pos + ) + + expect(suggestions).toHaveLength(1) + + const suggestionLabels = suggestions.map((sug) => sug.label) + expect(suggestionLabels).toEqual(['fk']) + }) + + it('should return the correct parameter completion items', async () => { + // The position inside the string in the parameters mapping right after the period, where the completion was triggered. + const [dummyConfigDocument, pos] = constructCompletionExample( + dummyCosimConfigTemplate, + 3 ) + cosimConfig = new CosimulationConfiguration(dummyConfigDocument) + const getFMUModelSpy = jest.spyOn(cosimConfig, 'getFMUModel') + getFMUModelSpy.mockImplementation(async (identifier: string) => { + if (identifier === '{fmu1}') { + return dummyModel + } + + return undefined + }) const suggestions = await simulationConfigCIP.getFMUVariableCompletionItems( @@ -79,10 +198,10 @@ describe('SimulationConfigCompletionItemProvider', () => { pos ) - expect(suggestions).toHaveLength(2) + expect(suggestions).toHaveLength(1) const suggestionLabels = suggestions.map((sug) => sug.label) - expect(suggestionLabels).toEqual(['v1', 'v2']) + expect(suggestionLabels).toEqual(['c1']) }) }) }) diff --git a/test/language-features/utils.unit.test.ts b/test/language-features/utils.unit.test.ts index 9c3d081..a8d3701 100644 --- a/test/language-features/utils.unit.test.ts +++ b/test/language-features/utils.unit.test.ts @@ -59,6 +59,11 @@ const fmuModel1: FMUModel = { name: 'vo1', }, ], + parameters: [ + { + name: 'vp1', + }, + ], } const fmuSource1: FMUSource = { @@ -77,6 +82,11 @@ const fmuModel2: FMUModel = { name: 'vo2', }, ], + parameters: [ + { + name: 'vp2', + }, + ], } const fmuSource2: FMUSource = { From b3b2d3a2bc0ae8eca7eae4236bf6887b3f0ab67d Mon Sep 17 00:00:00 2001 From: MarkusEllyton Date: Fri, 25 Oct 2024 12:16:13 +0200 Subject: [PATCH 2/6] Add GitHub action to test and build extension --- .github/workflows/package-vsix.yml | 39 ++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/package-vsix.yml diff --git a/.github/workflows/package-vsix.yml b/.github/workflows/package-vsix.yml new file mode 100644 index 0000000..ccb3489 --- /dev/null +++ b/.github/workflows/package-vsix.yml @@ -0,0 +1,39 @@ +name: Build and Test VS Code Extension + +on: + push: + branches: + - main + pull_request: + +jobs: + build-and-test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + node-version: [20] + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm install + + - name: Run tests + run: npm test:unit + + - name: Build the extension + run: npx vsce package + + - name: Upload .vsix artifact + uses: actions/upload-artifact@v3 + with: + name: vscode-extension-${{ matrix.os }} + path: '*.vsix' \ No newline at end of file From 1334aaaa131fe31c4207d005a047de0696153047 Mon Sep 17 00:00:00 2001 From: MarkusEllyton Date: Fri, 25 Oct 2024 12:21:49 +0200 Subject: [PATCH 3/6] Add temporary dev push trigger --- .github/workflows/package-vsix.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/package-vsix.yml b/.github/workflows/package-vsix.yml index ccb3489..c0fc840 100644 --- a/.github/workflows/package-vsix.yml +++ b/.github/workflows/package-vsix.yml @@ -3,7 +3,8 @@ name: Build and Test VS Code Extension on: push: branches: - - main + - cosimstudio_rework + - cosimstudio_rework_0.1.3 pull_request: jobs: From 85e545b598afa50ee64a39654d782c4e1fcdb70b Mon Sep 17 00:00:00 2001 From: MarkusEllyton Date: Fri, 25 Oct 2024 12:22:45 +0200 Subject: [PATCH 4/6] Fix action run test step --- .github/workflows/package-vsix.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/package-vsix.yml b/.github/workflows/package-vsix.yml index c0fc840..681c12e 100644 --- a/.github/workflows/package-vsix.yml +++ b/.github/workflows/package-vsix.yml @@ -28,7 +28,7 @@ jobs: run: npm install - name: Run tests - run: npm test:unit + run: npm run test:unit - name: Build the extension run: npx vsce package From a16e79d5a32591ac76525959814dbc10d633fd3e Mon Sep 17 00:00:00 2001 From: MarkusEllyton Date: Fri, 25 Oct 2024 12:26:11 +0200 Subject: [PATCH 5/6] Remove stray console.log --- src/language-features/completion-items.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/language-features/completion-items.ts b/src/language-features/completion-items.ts index bfc6314..6c228bf 100644 --- a/src/language-features/completion-items.ts +++ b/src/language-features/completion-items.ts @@ -86,8 +86,6 @@ export class SimulationConfigCompletionItemProvider ): Promise { const completionNode = cosimConfig.getNodeAtPosition(position) - console.log(completionNode) - if ( !completionNode || !isNodeString(completionNode) || From 1683df7622d0ebcc3de720b380d8d3796c75c04f Mon Sep 17 00:00:00 2001 From: MarkusEllyton Date: Fri, 25 Oct 2024 12:28:02 +0200 Subject: [PATCH 6/6] Update action versions to v4 --- .github/workflows/package-vsix.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/package-vsix.yml b/.github/workflows/package-vsix.yml index 681c12e..a0839ef 100644 --- a/.github/workflows/package-vsix.yml +++ b/.github/workflows/package-vsix.yml @@ -4,7 +4,6 @@ on: push: branches: - cosimstudio_rework - - cosimstudio_rework_0.1.3 pull_request: jobs: @@ -17,10 +16,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} @@ -34,7 +33,7 @@ jobs: run: npx vsce package - name: Upload .vsix artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: vscode-extension-${{ matrix.os }} path: '*.vsix' \ No newline at end of file