Skip to content

Commit

Permalink
Feat: Add definitions for embedded languages
Browse files Browse the repository at this point in the history
  • Loading branch information
idillon-sfl committed Jan 4, 2024
1 parent 4d73a66 commit 92accaf
Show file tree
Hide file tree
Showing 8 changed files with 363 additions and 2 deletions.
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

0 comments on commit 92accaf

Please sign in to comment.