From 92accaf079f4f3ac56e188018fb270eaa9b1569e Mon Sep 17 00:00:00 2001 From: idillon Date: Thu, 21 Dec 2023 10:33:06 -0500 Subject: [PATCH] Feat: Add definitions for embedded languages --- client/src/language/languageClient.ts | 2 + client/src/language/middlewareDefinition.ts | 152 ++++++++++++++++++ client/src/language/utils.ts | 71 +++++++- .../sources/meta-fixtures/command-wrapper.bb | 16 ++ .../sources/meta-fixtures/definition.bb | 15 ++ .../src/tests/command-wrapper.test.ts | 2 +- .../src/tests/definition.test.ts | 86 ++++++++++ integration-tests/src/utils/vscode-tools.ts | 21 +++ 8 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 client/src/language/middlewareDefinition.ts create mode 100644 integration-tests/project-folder/sources/meta-fixtures/command-wrapper.bb create mode 100644 integration-tests/src/tests/definition.test.ts create mode 100644 integration-tests/src/utils/vscode-tools.ts diff --git a/client/src/language/languageClient.ts b/client/src/language/languageClient.ts index db66b85f..91890ef2 100644 --- a/client/src/language/languageClient.ts +++ b/client/src/language/languageClient.ts @@ -22,6 +22,7 @@ import { NotificationMethod, type NotificationParams } from '../lib/src/types/no import { middlewareProvideCompletion } from './middlewareCompletion' import { middlewareProvideHover } from './middlewareHover' import { requestsManager } from './RequestManager' +import { middlewareProvideDefinition } from './middlewareDefinition' const notifyFileRenameChanged = async ( client: LanguageClient, @@ -62,6 +63,7 @@ export async function activateLanguageServer (context: ExtensionContext): Promis }, middleware: { provideCompletionItem: middlewareProvideCompletion, + provideDefinition: middlewareProvideDefinition, provideHover: middlewareProvideHover } } diff --git a/client/src/language/middlewareDefinition.ts b/client/src/language/middlewareDefinition.ts new file mode 100644 index 00000000..06d64d61 --- /dev/null +++ b/client/src/language/middlewareDefinition.ts @@ -0,0 +1,152 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) 2023 Savoir-faire Linux. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +import { Location, Position, Range, Uri, commands, type LocationLink, type TextDocument } from 'vscode' +import { type DefinitionMiddleware } from 'vscode-languageclient' + +import { getFileContent } from '../lib/src/utils/files' +import { requestsManager } from './RequestManager' +import { changeDefinitionUri, checkIsDefinitionRangeEqual, checkIsDefinitionUriEqual, convertToSameDefinitionType, getDefinitionUri, getEmbeddedLanguageDocPosition, getOriginalDocRange } from './utils' +import { type EmbeddedLanguageDocInfos } from '../lib/src/types/embedded-languages' +import { logger } from '../lib/src/utils/OutputLogger' + +export const middlewareProvideDefinition: DefinitionMiddleware['provideDefinition'] = async (document, position, token, next) => { + logger.debug(`[middlewareProvideDefinition] ${document.uri.toString()}, line ${position.line}, character ${position.character}`) + const nextResult = await next(document, position, token) + if ((Array.isArray(nextResult) && nextResult.length !== 0) || (!Array.isArray(nextResult) && nextResult !== undefined)) { + logger.debug('[middlewareProvideDefinition] returning nextResult') + return nextResult + } + const embeddedLanguageDocInfos = await requestsManager.getEmbeddedLanguageDocInfos(document.uri.toString(), position) + logger.debug(`[middlewareProvideDefinition] embeddedLanguageDoc ${embeddedLanguageDocInfos?.uri}`) + if (embeddedLanguageDocInfos === undefined || embeddedLanguageDocInfos === null) { + return + } + const embeddedLanguageDocContent = await getFileContent(Uri.parse(embeddedLanguageDocInfos.uri).fsPath) + if (embeddedLanguageDocContent === undefined) { + return + } + const adjustedPosition = getEmbeddedLanguageDocPosition( + document, + embeddedLanguageDocContent, + embeddedLanguageDocInfos.characterIndexes, + position + ) + const vdocUri = Uri.parse(embeddedLanguageDocInfos.uri) + const tempResult = await commands.executeCommand( + 'vscode.executeDefinitionProvider', + vdocUri, + adjustedPosition + ) + + // This check's purpose is only to please TypeScript. + // We'd rather have a pointless check than losing the type assurance provided by TypeScript. + if (checkIsArrayLocation(tempResult)) { + return await processDefinitions(tempResult, document, embeddedLanguageDocContent, embeddedLanguageDocInfos) + } else { + return await processDefinitions(tempResult, document, embeddedLanguageDocContent, embeddedLanguageDocInfos) + } +} + +const checkIsArrayLocation = (array: Location[] | LocationLink[]): array is Location[] => { + return array[0] instanceof Location +} + +const processDefinitions = async ( + definitions: DefinitionType[], + originalTextDocument: TextDocument, + embeddedLanguageDocContent: string, + embeddedLanguageDocInfos: EmbeddedLanguageDocInfos +): Promise => { + const result: DefinitionType[] = [] + await Promise.all(definitions.map(async (definition) => { + if (!checkIsDefinitionUriEqual(definition, Uri.parse(embeddedLanguageDocInfos.uri))) { + result.push(definition) // only definitions located on the embedded language documents need ajustments + return + } + if (embeddedLanguageDocInfos.language === 'python') { + for (const redirectionFunction of redirectionFunctions) { + const redirection = await redirectionFunction(definition) + if (redirection !== undefined) { + result.push(...redirection) + return + } + } + } + changeDefinitionUri(definition, originalTextDocument.uri) + ajustDefinitionRange(definition, originalTextDocument, embeddedLanguageDocContent, embeddedLanguageDocInfos.characterIndexes) + result.push(definition) + })) + return result +} + +// Map the range of the definitin from the embedded language document to the original document +const ajustDefinitionRange = ( + definition: Location | LocationLink, + originalTextDocument: TextDocument, + embeddedLanguageDocContent: string, + characterIndexes: number[] +): void => { + if (definition instanceof Location) { + const newRange = getOriginalDocRange(originalTextDocument, embeddedLanguageDocContent, characterIndexes, definition.range) + if (newRange !== undefined) { + definition.range = newRange + } + } else { + const newTargetRange = getOriginalDocRange(originalTextDocument, embeddedLanguageDocContent, characterIndexes, definition.targetRange) + if (newTargetRange !== undefined) { + definition.targetRange = newTargetRange + } + if (definition.targetSelectionRange !== undefined) { + const newTargetSelectionRange = getOriginalDocRange(originalTextDocument, embeddedLanguageDocContent, characterIndexes, definition.targetSelectionRange) + if (newTargetSelectionRange !== undefined) { + definition.targetSelectionRange = newTargetRange + } + } + } +} + +// Redirect a definition to an other definition +// For example, `d` of `d.getVar('')` is redirected to the definition of `data_smart.DataSmart()` +const redirectDefinition = async ( + initialDefinition: DefinitionType, // The definition that might be redirected + testedRange: Range, // The range for which a redirection would be made + redirectedPosition: Position // The new position to look at +): Promise => { + if (!checkIsDefinitionRangeEqual(initialDefinition, testedRange)) { + return + } + const uri = getDefinitionUri(initialDefinition) + const redirectedResult = await commands.executeCommand( + 'vscode.executeDefinitionProvider', + uri, + redirectedPosition + ) + // The middleware is expecting to return `Location[] | LocationLink[]`, not `(Location | LocationLink)[]` + // Ensure all the new definitions have the same type has the reference definition + return redirectedResult.map((redirectedDefinition) => convertToSameDefinitionType(initialDefinition, redirectedDefinition)) +} + +export const dRange = new Range(2, 0, 2, 1) // Where `d` is located in the embedded language document +export const dataSmartPosition = new Position(2, 19) // Where `DataSmart` (data_smart.DataSmart()) is reachable in the embedded language document + +// Handle `d` in `d.getVar('')` +const getDefinitionOfD = async ( + definition: DefinitionType +): Promise => { + return await redirectDefinition(definition, dRange, dataSmartPosition) +} + +export const eRange = new Range(4, 0, 4, 1) // Where `e` is located in the embedded language document +export const eventPosition = new Position(4, 12) // Where `Event` (event.Event()) is reachable in the embedded language document + +// Handle `e` in `e.data.getVar('')` +const getDefinitionOfE = async ( + definition: DefinitionType +): Promise => { + return await redirectDefinition(definition, eRange, eventPosition) +} + +const redirectionFunctions = [getDefinitionOfD, getDefinitionOfE] diff --git a/client/src/language/utils.ts b/client/src/language/utils.ts index 71bb265a..52753fd9 100644 --- a/client/src/language/utils.ts +++ b/client/src/language/utils.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. * ------------------------------------------------------------------------------------------ */ -import { Position, Range, type TextDocument } from 'vscode' +import { Location, type LocationLink, Position, Range, type Uri, type TextDocument } from 'vscode' export const getOriginalDocRange = ( originalTextDocument: TextDocument, @@ -66,3 +66,72 @@ const getOffset = (documentContent: string, position: Position): number => { offset += position.character return offset } + +export const checkIsPositionEqual = (position1: Position, position2: Position): boolean => { + return position1.line === position2.line && position1.character === position2.character +} + +export const checkIsRangeEqual = (range1: Range, range2: Range): boolean => { + return checkIsPositionEqual(range1.start, range2.start) && checkIsPositionEqual(range1.end, range2.end) +} + +export const checkIsDefinitionUriEqual = (definition: Location | LocationLink, uri: Uri): boolean => { + if (definition instanceof Location) { + return definition.uri.fsPath === uri.fsPath + } + return definition.targetUri.fsPath === uri.fsPath +} + +export const changeDefinitionUri = (definition: Location | LocationLink, uri: Uri): void => { + if (definition instanceof Location) { + definition.uri = uri + } else { + definition.targetUri = uri + } +} + +export const getDefinitionUri = (definition: Location | LocationLink): Uri => { + if (definition instanceof Location) { + return definition.uri + } + return definition.targetUri +} + +export const checkIsDefinitionRangeEqual = (definition: Location | LocationLink, range: Range): boolean => { + if (definition instanceof Location) { + return checkIsRangeEqual(definition.range, range) + } + return checkIsRangeEqual(definition.targetRange, range) +} + +export const convertDefinitionToLocation = (definition: Location | LocationLink): Location => { + if (definition instanceof Location) { + return definition + } + return { + uri: definition.targetUri, + range: definition.targetRange + } +} + +export const convertDefinitionToLocationLink = (definition: Location | LocationLink): LocationLink => { + if (definition instanceof Location) { + return { + targetUri: definition.uri, + targetRange: definition.range, + targetSelectionRange: definition.range + } + } + return definition +} + +export const convertToSameDefinitionType = ( + referenceDefinition: DefinitionType, + definitionToConvert: Location | LocationLink +): DefinitionType => { + if (referenceDefinition instanceof Location) { + return convertDefinitionToLocation(definitionToConvert) as DefinitionType + } else { + return convertDefinitionToLocationLink(definitionToConvert) as DefinitionType + } +} diff --git a/integration-tests/project-folder/sources/meta-fixtures/command-wrapper.bb b/integration-tests/project-folder/sources/meta-fixtures/command-wrapper.bb new file mode 100644 index 00000000..c298c33e --- /dev/null +++ b/integration-tests/project-folder/sources/meta-fixtures/command-wrapper.bb @@ -0,0 +1,16 @@ +inherit image + +python () { + d.getVar() +} + +TEST = "${@e.data.getVar()}" + +def test (): + d = '' + print(d) + +test() { + FOO='' + FOO +} diff --git a/integration-tests/project-folder/sources/meta-fixtures/definition.bb b/integration-tests/project-folder/sources/meta-fixtures/definition.bb index c8d6f49e..c298c33e 100644 --- a/integration-tests/project-folder/sources/meta-fixtures/definition.bb +++ b/integration-tests/project-folder/sources/meta-fixtures/definition.bb @@ -1 +1,16 @@ inherit image + +python () { + d.getVar() +} + +TEST = "${@e.data.getVar()}" + +def test (): + d = '' + print(d) + +test() { + FOO='' + FOO +} diff --git a/integration-tests/src/tests/command-wrapper.test.ts b/integration-tests/src/tests/command-wrapper.test.ts index fe8b4475..e49480a5 100644 --- a/integration-tests/src/tests/command-wrapper.test.ts +++ b/integration-tests/src/tests/command-wrapper.test.ts @@ -53,7 +53,7 @@ suite('Bitbake Command Wrapper', () => { }) test('Bitbake can properly scan includes inside a crops container', async () => { - const filePath = path.resolve(__dirname, '../../project-folder/sources/meta-fixtures/definition.bb') + const filePath = path.resolve(__dirname, '../../project-folder/sources/meta-fixtures/command-wrapper.bb') const docUri = vscode.Uri.parse(`file://${filePath}`) let definitions: vscode.Location[] = [] diff --git a/integration-tests/src/tests/definition.test.ts b/integration-tests/src/tests/definition.test.ts new file mode 100644 index 00000000..84f3f4d9 --- /dev/null +++ b/integration-tests/src/tests/definition.test.ts @@ -0,0 +1,86 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) 2023 Savoir-faire Linux. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +import * as assert from 'assert' +import * as vscode from 'vscode' +import path from 'path' +import { assertWillComeTrue } from '../utils/async' +import { checkIsRangeEqual, getDefinitionUri } from '../utils/vscode-tools' + +suite('Bitbake Definition Test Suite', () => { + const filePath = path.resolve(__dirname, '../../project-folder/sources/meta-fixtures/definition.bb') + const docUri = vscode.Uri.parse(`file://${filePath}`) + + suiteSetup(async function (this: Mocha.Context) { + this.timeout(100000) + const vscodeBitbake = vscode.extensions.getExtension('yocto-project.yocto-bitbake') + if (vscodeBitbake === undefined) { + assert.fail('Bitbake extension is not available') + } + await vscodeBitbake.activate() + await vscode.workspace.openTextDocument(docUri) + }) + + const testDefinition = async ( + position: vscode.Position, + expectedPathEnding: string, + expectedRange?: vscode.Range + ): Promise => { + let definitionResult: vscode.Location[] | vscode.Location[] = [] + + await assertWillComeTrue(async () => { + definitionResult = await vscode.commands.executeCommand( + 'vscode.executeDefinitionProvider', + docUri, + position + ) + return definitionResult.length > 0 + }) + definitionResult.forEach((definition) => { + const receivedUri = getDefinitionUri(definition) + assert.equal(receivedUri.fsPath.endsWith(expectedPathEnding), true) + if (expectedRange !== undefined) { + checkIsRangeEqual(definition.range, expectedRange) + } + }) + } + + test('Definition appears properly on inherit', async () => { + const position = new vscode.Position(0, 10) + const expectedPathEnding = 'meta/classes-recipe/image.bbclass' + await testDefinition(position, expectedPathEnding) + }).timeout(300000) + + test('Definition appears properly in Python on d', async () => { + const position = new vscode.Position(3, 3) + const expectedPathEnding = 'lib/bb/data_smart.py' + await testDefinition(position, expectedPathEnding) + }).timeout(300000) + + test('Definition appears properly in Python on the getVar part of d.getVar', async () => { + const position = new vscode.Position(3, 7) + const expectedPathEnding = 'lib/bb/data_smart.py' + await testDefinition(position, expectedPathEnding) + }).timeout(300000) + + test('Definition appears properly in Python on e', async () => { + const position = new vscode.Position(6, 12) + const expectedPathEnding = 'lib/bb/event.py' + await testDefinition(position, expectedPathEnding) + }).timeout(300000) + + test('Definition appears properly in Python on the getVar part of e.data.getVar', async () => { + const position = new vscode.Position(6, 21) + const expectedPathEnding = 'lib/bb/data_smart.py' + await testDefinition(position, expectedPathEnding) + }).timeout(300000) + + test('Definition appears properly on Bash variable', async () => { + const position = new vscode.Position(14, 3) + const expectedPathEnding = filePath + const expectedRange = new vscode.Range(13, 2, 13, 8) + await testDefinition(position, expectedPathEnding, expectedRange) + }).timeout(300000) +}) diff --git a/integration-tests/src/utils/vscode-tools.ts b/integration-tests/src/utils/vscode-tools.ts new file mode 100644 index 00000000..21799aa3 --- /dev/null +++ b/integration-tests/src/utils/vscode-tools.ts @@ -0,0 +1,21 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) 2023 Savoir-faire Linux. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +import { Location, type LocationLink, type Position, type Range, type Uri } from 'vscode' + +export const checkIsPositionEqual = (position1: Position, position2: Position): boolean => { + return position1.line === position2.line && position1.character === position2.character +} + +export const checkIsRangeEqual = (range1: Range, range2: Range): boolean => { + return checkIsPositionEqual(range1.start, range2.start) && checkIsPositionEqual(range1.end, range2.end) +} + +export const getDefinitionUri = (definition: Location | LocationLink): Uri => { + if (definition instanceof Location) { + return definition.uri + } + return definition.targetUri +}