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: Go to definition for uris in string content #23

Closed
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
40 changes: 39 additions & 1 deletion server/src/__tests__/definition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { analyzer } from '../tree-sitter/analyzer'
import { generateParser } from '../tree-sitter/parser'
import { onDefinitionHandler } from '../connectionHandlers/onDefinition'
import { FIXTURE_DOCUMENT, DUMMY_URI, FIXTURE_URI } from './fixtures/fixtures'
import { FIXTURE_DOCUMENT, DUMMY_URI, FIXTURE_URI, FIXTURE_FOLDER } from './fixtures/fixtures'
import path from 'path'
import { bitBakeProjectScannerClient } from '../BitbakeProjectScannerClient'

Expand Down Expand Up @@ -238,4 +238,42 @@ describe('on definition', () => {

expect(shouldNotWork).toEqual([])
})

it('provides go to definition for uris found in the string content', async () => {
await analyzer.analyze({
uri: DUMMY_URI,
document: FIXTURE_DOCUMENT.DIRECTIVE
})

analyzer.workspaceFolders = [{ uri: FIXTURE_FOLDER, name: 'test' }]

const shouldWork1 = onDefinitionHandler({
textDocument: {
uri: DUMMY_URI
},
position: {
line: 12,
character: 13
}
})

const shouldWork2 = onDefinitionHandler({
textDocument: {
uri: DUMMY_URI
},
position: {
line: 13,
character: 10
}
})

expect(shouldWork1).toEqual([
{
uri: FIXTURE_URI.FOO_INC,
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }
}
])

expect(shouldWork2).toEqual(shouldWork1)
})
})
5 changes: 4 additions & 1 deletion server/src/__tests__/fixtures/directive.bb
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ FOO = '${APPEND}'
SYMBOL_IN_STRING = 'hover is a package ${FOO} \
parentFolder/hover should also be seen as symbol \
this hover too, other words should not. \
'
'

SRC_URI = 'file://foo.inc \
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weird for a bitbake file to provide the same URI twice. Not sure it would be compile through bitbake.

file://foo.inc'
2 changes: 1 addition & 1 deletion server/src/__tests__/fixtures/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import path from 'path'
import fs from 'fs'
import { TextDocument } from 'vscode-languageserver-textdocument'

const FIXTURE_FOLDER = path.join(__dirname, './')
export const FIXTURE_FOLDER = path.join(__dirname, './')

type FIXTURE_URI_KEY = keyof typeof FIXTURE_URI

Expand Down
60 changes: 51 additions & 9 deletions server/src/connectionHandlers/onDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { type DirectiveStatementKeyword } from '../lib/src/types/directiveKeywor
import { bitBakeProjectScannerClient } from '../BitbakeProjectScannerClient'
import path, { type ParsedPath } from 'path'
import { type ElementInfo } from '../lib/src/types/BitbakeScanResult'
import fs from 'fs'

export function onDefinitionHandler (textDocumentPositionParams: TextDocumentPositionParams): Definition | null {
const { textDocument: { uri: documentUri }, position } = textDocumentPositionParams
Expand Down Expand Up @@ -72,16 +73,38 @@ export function onDefinitionHandler (textDocumentPositionParams: TextDocumentPos
}
// Symbols in string content
if (analyzer.isStringContent(documentUri, position.line, position.character)) {
const allSymbolsFoundAtPosition = analyzer.getSymbolInStringContentForPosition(documentUri, position.line, position.character)
if (allSymbolsFoundAtPosition !== undefined) {
allSymbolsFoundAtPosition.forEach((symbol) => {
definitions.push({
uri: symbol.location.uri,
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }
})
})
return definitions
const wholeWordRegex = /(?<![-.:])\b(\w+)\b(?![-.:])/g
const uriRegex = /(file:\/\/)(?<uri>.*)\b/g
const [uriAtPosition] = analyzer.getSymbolsInStringContent(documentUri, position.line, position.character, uriRegex)
if (uriAtPosition !== undefined) {
const { workspaceFolders } = analyzer
if (workspaceFolders !== undefined && workspaceFolders !== null) {
for (const workspaceFolder of workspaceFolders) {
const filePath = findFileInDirectory(workspaceFolder.uri.replace('file://', ''), uriAtPosition.name)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user's workspace may contain several yocto layers and build directories which are not ignored here. Looking up the whole workspaceFolder will be inaccurate and slow. I suggest rather searching only the directory or immediate parent of the recipe file.

if (filePath !== null) {
definitions.push(
{
uri: 'file://' + filePath,
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }
}
)
break
}
}
return definitions
}
}

const allSymbolsAtPosition = analyzer.getSymbolsInStringContent(documentUri, position.line, position.character, wholeWordRegex)

allSymbolsAtPosition.forEach((symbol) => {
definitions.push({
uri: symbol.location.uri,
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }
})
})

return definitions
}
}

Expand Down Expand Up @@ -133,3 +156,22 @@ function createDefinitionLocationForPathInfo (path: ParsedPath): Location {

return location
}

function findFileInDirectory (dir: string, fileName: string): string | null {
try {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a library for finding files, I don't think there is added value in defining this ourselves.
Use https://code.visualstudio.com/api/references/vscode-api#workspace.findFiles
(or alternatively https://github.com/isaacs/node-glob)

const filePaths = fs.readdirSync(dir).map(name => path.join(dir, name))
for (const filePath of filePaths) {
if (fs.statSync(filePath).isDirectory()) {
const result = findFileInDirectory(filePath, fileName)
if (result !== null) return result
} else if (path.basename(filePath) === fileName) {
return filePath
}
}
} catch {
logger.debug(`[findFileInDirectory] ${dir} not found`)
return null
}

return null
}
1 change: 1 addition & 0 deletions server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ connection.onInitialize(async (params: InitializeParams): Promise<InitializeResu

const parser = await generateParser()
analyzer.initialize(parser)
analyzer.workspaceFolders = params.workspaceFolders

bitBakeProjectScannerClient.onChange.on('scanReady', () => {
logger.debug('[On scanReady] Analyzing the current document again...')
Expand Down
132 changes: 65 additions & 67 deletions server/src/tree-sitter/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
type Diagnostic,
type SymbolInformation,
type Range,
SymbolKind
SymbolKind,
type WorkspaceFolder
} from 'vscode-languageserver'
import type Parser from 'web-tree-sitter'
import { TextDocument } from 'vscode-languageserver-textdocument'
Expand All @@ -34,13 +35,13 @@ interface AnalyzedDocument {
embeddedRegions: EmbeddedRegions
tree: Parser.Tree
extraSymbols?: GlobalDeclarations[] // symbols from the include files
symbolsInStringContent?: SymbolInformation[]
}

export default class Analyzer {
private parser?: Parser
private uriToAnalyzedDocument: Record<string, AnalyzedDocument | undefined> = {}
private debouncedExecuteAnalyzation?: ReturnType<typeof debounce>
public workspaceFolders: WorkspaceFolder[] | undefined | null = []

public getDocumentTexts (uri: string): string[] | undefined {
return this.uriToAnalyzedDocument[uri]?.document.getText().split(/\r?\n/g)
Expand Down Expand Up @@ -74,7 +75,6 @@ export default class Analyzer {

const tree = this.parser.parse(fileContent)
const globalDeclarations = getGlobalDeclarations({ tree, uri })
const symbolsInStringContent = this.getSymbolsInStringContent(tree, uri)
const embeddedRegions = getEmbeddedRegionsFromNode(tree, uri)
/* eslint-disable-next-line prefer-const */
let extraSymbols: GlobalDeclarations[] = []
Expand All @@ -85,8 +85,7 @@ export default class Analyzer {
globalDeclarations,
embeddedRegions,
tree,
extraSymbols,
symbolsInStringContent
extraSymbols
}

let debouncedExecuteAnalyzation = this.debouncedExecuteAnalyzation
Expand Down Expand Up @@ -516,85 +515,84 @@ export default class Analyzer {
/**
* Extract symbols from the string content of the tree
*/
public getSymbolsInStringContent (tree: Parser.Tree, uri: string): SymbolInformation[] {
const symbolInformation: SymbolInformation[] = []
const wholeWordRegex = /(?<![-.:])\b(\w+)\b(?![-.:])/g
TreeSitterUtils.forEach(tree.rootNode, (n) => {
if (n.type === 'string_content') {
const splittedStringContent = n.text.split(/\n/g)
for (let i = 0; i < splittedStringContent.length; i++) {
const line = splittedStringContent[i]
for (const match of line.matchAll(wholeWordRegex)) {
if (match !== undefined && uri !== undefined) {
const start = {
line: n.startPosition.row + i,
character: match.index !== undefined ? match.index + n.startPosition.column : 0
}
const end = {
line: n.startPosition.row + i,
character: match.index !== undefined ? match.index + n.startPosition.column + match[0].length : 0
}
if (i > 0) {
start.character = match.index ?? 0
end.character = (match.index ?? 0) + match[0].length
public getSymbolsInStringContent (uri: string, line: number, character: number, regex: RegExp): SymbolInformation[] {
const allSymbolsAtPosition: SymbolInformation[] = []
const n = this.nodeAtPoint(uri, line, character)
if (n?.type === 'string_content') {
const splittedStringContent = n.text.split(/\n/g)
for (let i = 0; i < splittedStringContent.length; i++) {
const lineText = splittedStringContent[i]
for (const match of lineText.matchAll(regex)) {
const matchedUri = match.groups?.uri
const start = {
line: n.startPosition.row + i,
character: match.index !== undefined ? match.index + n.startPosition.column : 0
}
const end = {
line: n.startPosition.row + i,
character: match.index !== undefined ? match.index + n.startPosition.column + match[0].length : 0
}
if (i > 0) {
start.character = match.index ?? 0
end.character = (match.index ?? 0) + match[0].length
}
if (this.positionIsInRange(line, character, { start, end })) {
if (matchedUri !== undefined) {
return [{
name: matchedUri,
kind: SymbolKind.String,
location: {
range: {
start,
end
},
uri
}
}]
}
const foundRecipe = bitBakeProjectScannerClient.bitbakeScanResult._recipes.find((recipe) => {
return recipe.name === match[0]
})
if (foundRecipe !== undefined) {
if (foundRecipe?.path !== undefined) {
allSymbolsAtPosition.push({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure there is a need for go to definition for URIs. They are usually related only to the current file itself. Why could several recipe files be matched? Only the opened one should be suggested I think.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of providing "Go to definition", register a https://code.visualstudio.com/api/references/vscode-api#DocumentLinkProvider
This will make ctrl+click work instead of manually selecting "go to definition".

name: match[0],
kind: SymbolKind.Variable,
location: {
range: {
start,
end
},
uri: 'file://' + foundRecipe.path.dir + '/' + foundRecipe.path.base
}
})
}
const foundRecipe = bitBakeProjectScannerClient.bitbakeScanResult._recipes.find((recipe) => {
return recipe.name === match[0]
})
if (foundRecipe !== undefined) {
if (foundRecipe?.path !== undefined) {
symbolInformation.push({
name: match[0],
if (foundRecipe?.appends !== undefined && foundRecipe.appends.length > 0) {
foundRecipe.appends.forEach((append) => {
allSymbolsAtPosition.push({
name: append.name,
kind: SymbolKind.Variable,
location: {
range: {
start,
end
},
uri: 'file://' + foundRecipe.path.dir + '/' + foundRecipe.path.base
uri: 'file://' + append.dir + '/' + append.base
}
})
}
if (foundRecipe?.appends !== undefined && foundRecipe.appends.length > 0) {
foundRecipe.appends.forEach((append) => {
symbolInformation.push({
name: append.name,
kind: SymbolKind.Variable,
location: {
range: {
start,
end
},
uri: 'file://' + append.dir + '/' + append.base
}
})
})
}
})
}
}
}
}
}
return true
})
}

return symbolInformation
return allSymbolsAtPosition
}

public getSymbolInStringContentForPosition (uri: string, line: number, column: number): SymbolInformation[] | undefined {
const analyzedDocument = this.uriToAnalyzedDocument[uri]
if (analyzedDocument?.symbolsInStringContent !== undefined) {
const { symbolsInStringContent } = analyzedDocument
const allSymbolsFoundAtPosition: SymbolInformation[] = [] // recipe + appends
for (const symbol of symbolsInStringContent) {
const { location: { range } } = symbol
if (line === range.start.line && column >= range.start.character && column <= range.end.character) {
allSymbolsFoundAtPosition.push(symbol)
}
}
return allSymbolsFoundAtPosition
}
return undefined
public positionIsInRange (line: number, character: number, range: Range): boolean {
return line === range.start.line && character >= range.start.character && character <= range.end.character
}
}

Expand Down
Loading