Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Add definitions for embedded languages #38

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/src/language/languageClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -62,6 +63,7 @@ export async function activateLanguageServer (context: ExtensionContext): Promis
},
middleware: {
provideCompletionItem: middlewareProvideCompletion,
provideDefinition: middlewareProvideDefinition,
provideHover: middlewareProvideHover
}
}
Expand Down
152 changes: 152 additions & 0 deletions client/src/language/middlewareDefinition.ts
Original file line number Diff line number Diff line change
@@ -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<Location[] | LocationLink[]>(
'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 <DefinitionType extends Location | LocationLink>(
definitions: DefinitionType[],
originalTextDocument: TextDocument,
embeddedLanguageDocContent: string,
embeddedLanguageDocInfos: EmbeddedLanguageDocInfos
): Promise<DefinitionType[]> => {
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 <DefinitionType extends Location | LocationLink>(
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<DefinitionType[] | undefined> => {
if (!checkIsDefinitionRangeEqual(initialDefinition, testedRange)) {
return
}
const uri = getDefinitionUri(initialDefinition)
const redirectedResult = await commands.executeCommand<Location[] | LocationLink[]>(
'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 <DefinitionType extends Location | LocationLink>(
definition: DefinitionType
): Promise<DefinitionType[] | undefined> => {
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 <DefinitionType extends Location | LocationLink>(
definition: DefinitionType
): Promise<DefinitionType[] | undefined> => {
return await redirectDefinition(definition, eRange, eventPosition)
}

const redirectionFunctions = [getDefinitionOfD, getDefinitionOfE]
71 changes: 70 additions & 1 deletion client/src/language/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = <DefinitionType extends Location | LocationLink>(
referenceDefinition: DefinitionType,
definitionToConvert: Location | LocationLink
): DefinitionType => {
if (referenceDefinition instanceof Location) {
return convertDefinitionToLocation(definitionToConvert) as DefinitionType
} else {
return convertDefinitionToLocationLink(definitionToConvert) as DefinitionType
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
inherit image

python () {
d.getVar()
}

TEST = "${@e.data.getVar()}"

def test ():
d = ''
print(d)

test() {
FOO=''
FOO
}
Original file line number Diff line number Diff line change
@@ -1 +1,16 @@
inherit image

python () {
d.getVar()
}

TEST = "${@e.data.getVar()}"

def test ():
d = ''
print(d)

test() {
FOO=''
FOO
}
2 changes: 1 addition & 1 deletion integration-tests/src/tests/command-wrapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []

Expand Down
86 changes: 86 additions & 0 deletions integration-tests/src/tests/definition.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
let definitionResult: vscode.Location[] | vscode.Location[] = []

await assertWillComeTrue(async () => {
definitionResult = await vscode.commands.executeCommand<vscode.Location[] | vscode.Location[]>(
'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)
})
Loading
Loading