From d1e1e520dc41163fa3b9a0345ce4c9e1b22147b7 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Mar 2026 18:48:02 +0000
Subject: [PATCH 01/10] Initial plan
From 072beff7c9536fa9c84ca84f1327e9fba9aaa2c3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Mar 2026 19:01:48 +0000
Subject: [PATCH 02/10] Add go-to-definition for XML script tag URI
Co-authored-by: TwitchBronBron <2544493+TwitchBronBron@users.noreply.github.com>
---
.../definition/DefinitionProvider.spec.ts | 52 +++++++++++++++++++
.../definition/DefinitionProvider.ts | 14 +++++
2 files changed, 66 insertions(+)
diff --git a/src/bscPlugin/definition/DefinitionProvider.spec.ts b/src/bscPlugin/definition/DefinitionProvider.spec.ts
index ae43ef351..0e3ae7125 100644
--- a/src/bscPlugin/definition/DefinitionProvider.spec.ts
+++ b/src/bscPlugin/definition/DefinitionProvider.spec.ts
@@ -194,4 +194,56 @@ describe('DefinitionProvider', () => {
range: util.createRange(1, 0, 1, 0)
}]);
});
+
+ it('handles script tag uri go-to-definition', () => {
+ const brsFile = program.setFile('components/MainScene.brs', `
+ sub main()
+ end sub
+ `);
+ const xmlFile = program.setFile('components/MainScene.xml', `
+
+
+
+ `);
+ // Line 2 (0-indexed): ` `
+ // The uri value range starts at the opening `"` for `pkg:/components/MainScene.brs`
+ expect(
+ program.getDefinition(xmlFile.srcPath, util.createPosition(2, 60))
+ ).to.eql([{
+ uri: URI.file(brsFile.srcPath).toString(),
+ range: util.createRange(0, 0, 0, 0)
+ }]);
+ });
+
+ it('handles script tag uri go-to-definition with relative path', () => {
+ const brsFile = program.setFile('components/MainScene.brs', `
+ sub main()
+ end sub
+ `);
+ const xmlFile = program.setFile('components/MainScene.xml', `
+
+
+
+ `);
+ // Line 2 (0-indexed): ` `
+ // The uri value range starts at the opening `"` for `MainScene.brs`
+ expect(
+ program.getDefinition(xmlFile.srcPath, util.createPosition(2, 54))
+ ).to.eql([{
+ uri: URI.file(brsFile.srcPath).toString(),
+ range: util.createRange(0, 0, 0, 0)
+ }]);
+ });
+
+ it('returns empty array when script tag uri file is not found', () => {
+ const xmlFile = program.setFile('components/MainScene.xml', `
+
+
+
+ `);
+ // click within "pkg:/components/NotFound.brs" uri value
+ expect(
+ program.getDefinition(xmlFile.srcPath, util.createPosition(2, 60))
+ ).to.eql([]);
+ });
});
diff --git a/src/bscPlugin/definition/DefinitionProvider.ts b/src/bscPlugin/definition/DefinitionProvider.ts
index 69b6a8318..0860d4f4f 100644
--- a/src/bscPlugin/definition/DefinitionProvider.ts
+++ b/src/bscPlugin/definition/DefinitionProvider.ts
@@ -258,5 +258,19 @@ export class DefinitionProvider {
uri: util.pathToUri(file.parentComponent.srcPath)
});
}
+
+ //if the position is within a script tag's uri attribute
+ for (const scriptImport of file.scriptTagImports) {
+ if (scriptImport.filePathRange && util.rangeContains(scriptImport.filePathRange, this.event.position)) {
+ const scriptFile = this.event.program.getFile(scriptImport.pkgPath);
+ if (scriptFile) {
+ this.event.definitions.push({
+ range: util.createRange(0, 0, 0, 0),
+ uri: util.pathToUri(scriptFile.srcPath)
+ });
+ }
+ break;
+ }
+ }
}
}
From 97b0f0939c8079e0476d4350d25836f5405d0c4b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Mar 2026 19:53:51 +0000
Subject: [PATCH 03/10] Add document links for XML script tag URI attributes
Co-authored-by: TwitchBronBron <2544493+TwitchBronBron@users.noreply.github.com>
---
src/LanguageServer.ts | 15 +++++-
src/Program.ts | 23 +++++++-
.../definition/DefinitionProvider.spec.ts | 52 +++++++++++++++++++
src/lsp/LspProject.ts | 7 ++-
src/lsp/Project.ts | 7 +++
src/lsp/ProjectManager.ts | 16 +++++-
src/lsp/worker/WorkerThreadProject.ts | 6 ++-
7 files changed, 120 insertions(+), 6 deletions(-)
diff --git a/src/LanguageServer.ts b/src/LanguageServer.ts
index 2a77f1d48..1cce2fa45 100644
--- a/src/LanguageServer.ts
+++ b/src/LanguageServer.ts
@@ -26,7 +26,9 @@ import type {
CompletionList,
CancellationToken,
DidChangeConfigurationParams,
- DidChangeConfigurationRegistrationOptions
+ DidChangeConfigurationRegistrationOptions,
+ DocumentLinkParams,
+ DocumentLink
} from 'vscode-languageserver/node';
import {
SemanticTokensRequest,
@@ -221,6 +223,9 @@ export class LanguageServer {
},
definitionProvider: true,
hoverProvider: true,
+ documentLinkProvider: {
+ resolveProvider: false
+ },
executeCommandProvider: {
commands: [
CustomCommands.TranspileFile
@@ -582,6 +587,14 @@ export class LanguageServer {
return result;
}
+ @AddStackToErrorMessage
+ public async onDocumentLinks(params: DocumentLinkParams): Promise {
+ this.logger.debug('onDocumentLinks', params);
+
+ const srcPath = util.uriToPath(params.textDocument.uri);
+ return this.projectManager.getDocumentLinks({ srcPath: srcPath });
+ }
+
@AddStackToErrorMessage
public async onSignatureHelp(params: SignatureHelpParams) {
this.logger.debug('onSignatureHelp', params);
diff --git a/src/Program.ts b/src/Program.ts
index 33158af17..c505a9cb8 100644
--- a/src/Program.ts
+++ b/src/Program.ts
@@ -1,7 +1,7 @@
import * as assert from 'assert';
import * as fsExtra from 'fs-extra';
import * as path from 'path';
-import type { CodeAction, CompletionItem, Position, Range, SignatureInformation, Location, DocumentSymbol, CancellationToken } from 'vscode-languageserver';
+import type { CodeAction, CompletionItem, Position, Range, SignatureInformation, Location, DocumentSymbol, CancellationToken, DocumentLink } from 'vscode-languageserver';
import { CancellationTokenSource, CompletionItemKind } from 'vscode-languageserver';
import type { BsConfig, FinalizedBsConfig } from './BsConfig';
import { Scope } from './Scope';
@@ -1021,8 +1021,27 @@ export class Program {
}
/**
- * Get hover information for a file and position
+ * Get document links (clickable URI ranges) for the specified file.
+ * This is used to make script tag URI attributes in XML files single-clickable links.
*/
+ public getDocumentLinks(srcPath: string): DocumentLink[] {
+ const file = this.getFile(srcPath);
+ if (!isXmlFile(file)) {
+ return [];
+ }
+ const links: DocumentLink[] = [];
+ for (const scriptImport of file.scriptTagImports) {
+ if (scriptImport.filePathRange) {
+ const scriptFile = this.getFile(scriptImport.pkgPath);
+ links.push({
+ range: scriptImport.filePathRange,
+ target: scriptFile ? util.pathToUri(scriptFile.srcPath) : undefined
+ });
+ }
+ }
+ return links;
+ }
+
public getHover(srcPath: string, position: Position): Hover[] {
let file = this.getFile(srcPath);
let result: Hover[];
diff --git a/src/bscPlugin/definition/DefinitionProvider.spec.ts b/src/bscPlugin/definition/DefinitionProvider.spec.ts
index 0e3ae7125..12033d83e 100644
--- a/src/bscPlugin/definition/DefinitionProvider.spec.ts
+++ b/src/bscPlugin/definition/DefinitionProvider.spec.ts
@@ -246,4 +246,56 @@ describe('DefinitionProvider', () => {
program.getDefinition(xmlFile.srcPath, util.createPosition(2, 60))
).to.eql([]);
});
+
+ describe('getDocumentLinks', () => {
+ it('returns document links for script tag uris', () => {
+ const brsFile = program.setFile('components/MainScene.brs', `
+ sub main()
+ end sub
+ `);
+ const xmlFile = program.setFile('components/MainScene.xml', `
+
+
+
+ `);
+ const links = program.getDocumentLinks(xmlFile.srcPath);
+ expect(links).to.be.lengthOf(1);
+ expect(links[0].target).to.equal(URI.file(brsFile.srcPath).toString());
+ });
+
+ it('returns document link with undefined target when script file is not found', () => {
+ const xmlFile = program.setFile('components/MainScene.xml', `
+
+
+
+ `);
+ const links = program.getDocumentLinks(xmlFile.srcPath);
+ expect(links).to.be.lengthOf(1);
+ expect(links[0].target).to.be.undefined;
+ });
+
+ it('returns empty array for non-xml files', () => {
+ const brsFile = program.setFile('source/main.brs', `
+ sub main()
+ end sub
+ `);
+ const links = program.getDocumentLinks(brsFile.srcPath);
+ expect(links).to.eql([]);
+ });
+
+ it('returns multiple links for multiple script tags', () => {
+ const brsFile1 = program.setFile('components/MainScene.brs', ``);
+ const brsFile2 = program.setFile('components/Helpers.brs', ``);
+ const xmlFile = program.setFile('components/MainScene.xml', `
+
+
+
+
+ `);
+ const links = program.getDocumentLinks(xmlFile.srcPath);
+ expect(links).to.be.lengthOf(2);
+ expect(links[0].target).to.equal(URI.file(brsFile1.srcPath).toString());
+ expect(links[1].target).to.equal(URI.file(brsFile2.srcPath).toString());
+ });
+ });
});
diff --git a/src/lsp/LspProject.ts b/src/lsp/LspProject.ts
index 34fc6926a..4857de723 100644
--- a/src/lsp/LspProject.ts
+++ b/src/lsp/LspProject.ts
@@ -1,4 +1,4 @@
-import type { Diagnostic, Position, Range, Location, DocumentSymbol, WorkspaceSymbol, CodeAction, CompletionList } from 'vscode-languageserver-protocol';
+import type { Diagnostic, Position, Range, Location, DocumentSymbol, WorkspaceSymbol, CodeAction, CompletionList, DocumentLink } from 'vscode-languageserver-protocol';
import type { Hover, MaybePromise, SemanticToken } from '../interfaces';
import type { DocumentAction, DocumentActionWithStatus } from './DocumentManager';
import type { FileTranspileResult, SignatureInfoObj } from '../Program';
@@ -142,6 +142,11 @@ export interface LspProject {
*/
getCodeActions(options: { srcPath: string; range: Range }): Promise;
+ /**
+ * Get document links (clickable URI ranges) for the specified file
+ */
+ getDocumentLinks(options: { srcPath: string }): MaybePromise;
+
/**
* Get the completions for the specified file and position
*/
diff --git a/src/lsp/Project.ts b/src/lsp/Project.ts
index c4b6b7ce8..911886833 100644
--- a/src/lsp/Project.ts
+++ b/src/lsp/Project.ts
@@ -432,6 +432,13 @@ export class Project implements LspProject {
}
}
+ public async getDocumentLinks(options: { srcPath: string }) {
+ await this.onIdle();
+ if (this.builder.program.hasFile(options.srcPath)) {
+ return this.builder.program.getDocumentLinks(options.srcPath);
+ }
+ }
+
public async getCompletions(options: { srcPath: string; position: Position }): Promise {
await this.onIdle();
diff --git a/src/lsp/ProjectManager.ts b/src/lsp/ProjectManager.ts
index db94a733c..fcf074703 100644
--- a/src/lsp/ProjectManager.ts
+++ b/src/lsp/ProjectManager.ts
@@ -6,7 +6,7 @@ import type { LspDiagnostic, LspProject, ProjectConfig } from './LspProject';
import { Project } from './Project';
import { WorkerThreadProject } from './worker/WorkerThreadProject';
import { FileChangeType } from 'vscode-languageserver-protocol';
-import type { Hover, Position, Range, Location, SignatureHelp, DocumentSymbol, SymbolInformation, WorkspaceSymbol, CompletionList, CancellationToken } from 'vscode-languageserver-protocol';
+import type { Hover, Position, Range, Location, SignatureHelp, DocumentSymbol, SymbolInformation, WorkspaceSymbol, CompletionList, CancellationToken, DocumentLink } from 'vscode-languageserver-protocol';
import { Deferred } from '../deferred';
import type { DocumentActionWithStatus, FlushEvent } from './DocumentManager';
import { DocumentManager } from './DocumentManager';
@@ -668,6 +668,20 @@ export class ProjectManager {
return result;
}
+ @TrackBusyStatus
+ public async getDocumentLinks(options: { srcPath: string }): Promise {
+ //wait for all pending syncs to finish
+ await this.onIdle();
+
+ //Ask every project for document links, keep whichever one responds first that has a valid response
+ let result = await util.promiseRaceMatch(
+ this.projects.map(x => x.getDocumentLinks(options)),
+ //keep the first non-falsey result
+ (result) => !!result
+ );
+ return result ?? [];
+ }
+
/**
* Scan a given workspace for all `bsconfig.json` files. If at least one is found, then only folders who have bsconfig.json are returned.
* If none are found, then the workspaceFolder itself is treated as a project
diff --git a/src/lsp/worker/WorkerThreadProject.ts b/src/lsp/worker/WorkerThreadProject.ts
index 55e152e23..a1b313513 100644
--- a/src/lsp/worker/WorkerThreadProject.ts
+++ b/src/lsp/worker/WorkerThreadProject.ts
@@ -10,7 +10,7 @@ import type { Hover, MaybePromise, SemanticToken } from '../../interfaces';
import type { DocumentAction, DocumentActionWithStatus } from '../DocumentManager';
import { Deferred } from '../../deferred';
import type { FileTranspileResult, SignatureInfoObj } from '../../Program';
-import type { Position, Range, Location, DocumentSymbol, WorkspaceSymbol, CodeAction, CompletionList } from 'vscode-languageserver-protocol';
+import type { Position, Range, Location, DocumentSymbol, WorkspaceSymbol, CodeAction, CompletionList, DocumentLink } from 'vscode-languageserver-protocol';
import type { Logger } from '../../logging';
import { createLogger } from '../../logging';
import * as fsExtra from 'fs-extra';
@@ -244,6 +244,10 @@ export class WorkerThreadProject implements LspProject {
return this.sendStandardRequest('getCodeActions', options);
}
+ public async getDocumentLinks(options: { srcPath: string }): Promise {
+ return this.sendStandardRequest('getDocumentLinks', options);
+ }
+
public async getCompletions(options: { srcPath: string; position: Position }): Promise {
return this.sendStandardRequest('getCompletions', options);
}
From 2a9130a7ef106fbbdb845a09e056babc1cb01815 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Mar 2026 23:09:58 +0000
Subject: [PATCH 04/10] Add plugin hooks for document links and use
LocationLink for Ctrl-only underline
Co-authored-by: TwitchBronBron <2544493+TwitchBronBron@users.noreply.github.com>
---
src/LanguageServer.spec.ts | 8 +-
src/LanguageServer.ts | 17 +---
src/Program.ts | 72 ++++++++++----
src/Scope.ts | 4 +-
src/XmlScope.spec.ts | 2 +-
src/XmlScope.ts | 4 +-
src/bscPlugin/BscPlugin.ts | 7 +-
.../definition/DefinitionProvider.spec.ts | 77 +++------------
.../definition/DefinitionProvider.ts | 4 +-
.../DocumentLinksProvider.spec.ts | 97 +++++++++++++++++++
.../documentLinks/DocumentLinksProvider.ts | 28 ++++++
src/files/BrsFile.ts | 4 +-
src/interfaces.ts | 44 ++++++++-
src/lsp/LspProject.ts | 9 +-
src/lsp/Project.ts | 11 +--
src/lsp/ProjectManager.ts | 18 +---
src/lsp/worker/WorkerThreadProject.ts | 10 +-
17 files changed, 264 insertions(+), 152 deletions(-)
create mode 100644 src/bscPlugin/documentLinks/DocumentLinksProvider.spec.ts
create mode 100644 src/bscPlugin/documentLinks/DocumentLinksProvider.ts
diff --git a/src/LanguageServer.spec.ts b/src/LanguageServer.spec.ts
index 48535c302..3283c51fb 100644
--- a/src/LanguageServer.spec.ts
+++ b/src/LanguageServer.spec.ts
@@ -1554,7 +1554,7 @@ describe('LanguageServer', () => {
});
expect(locations.length).to.equal(1);
- const location: Location = locations[0];
+ const location: Location = locations[0] as Location;
expect(location.uri).to.equal(functionDocument.uri);
expect(location.range.start.line).to.equal(5);
expect(location.range.start.character).to.equal(16);
@@ -1569,7 +1569,7 @@ describe('LanguageServer', () => {
});
expect(locations.length).to.equal(1);
- const location: Location = locations[0];
+ const location: Location = locations[0] as Location;
expect(location.uri).to.equal(functionDocument.uri);
expect(location.range.start.line).to.equal(5);
expect(location.range.start.character).to.equal(16);
@@ -1594,7 +1594,7 @@ describe('LanguageServer', () => {
position: util.createPosition(3, 36)
});
expect(locations.length).to.equal(1);
- const location: Location = locations[0];
+ const location: Location = locations[0] as Location;
expect(location.uri).to.equal(referenceDocument.uri);
expect(location.range.start.line).to.equal(2);
expect(location.range.start.character).to.equal(20);
@@ -1629,7 +1629,7 @@ describe('LanguageServer', () => {
position: util.createPosition(3, 30)
});
expect(locations.length).to.equal(1);
- const location: Location = locations[0];
+ const location: Location = locations[0] as Location;
expect(location.uri).to.equal(functionDocument.uri);
expect(location.range.start.line).to.equal(2);
expect(location.range.start.character).to.equal(20);
diff --git a/src/LanguageServer.ts b/src/LanguageServer.ts
index 1cce2fa45..9be8ca54a 100644
--- a/src/LanguageServer.ts
+++ b/src/LanguageServer.ts
@@ -26,9 +26,7 @@ import type {
CompletionList,
CancellationToken,
DidChangeConfigurationParams,
- DidChangeConfigurationRegistrationOptions,
- DocumentLinkParams,
- DocumentLink
+ DidChangeConfigurationRegistrationOptions
} from 'vscode-languageserver/node';
import {
SemanticTokensRequest,
@@ -223,9 +221,6 @@ export class LanguageServer {
},
definitionProvider: true,
hoverProvider: true,
- documentLinkProvider: {
- resolveProvider: false
- },
executeCommandProvider: {
commands: [
CustomCommands.TranspileFile
@@ -583,18 +578,10 @@ export class LanguageServer {
const srcPath = util.uriToPath(params.textDocument.uri);
- const result = this.projectManager.getDefinition({ srcPath: srcPath, position: params.position });
+ const result = await this.projectManager.getDefinition({ srcPath: srcPath, position: params.position });
return result;
}
- @AddStackToErrorMessage
- public async onDocumentLinks(params: DocumentLinkParams): Promise {
- this.logger.debug('onDocumentLinks', params);
-
- const srcPath = util.uriToPath(params.textDocument.uri);
- return this.projectManager.getDocumentLinks({ srcPath: srcPath });
- }
-
@AddStackToErrorMessage
public async onSignatureHelp(params: SignatureHelpParams) {
this.logger.debug('onSignatureHelp', params);
diff --git a/src/Program.ts b/src/Program.ts
index c505a9cb8..17ed59f7b 100644
--- a/src/Program.ts
+++ b/src/Program.ts
@@ -1,14 +1,14 @@
import * as assert from 'assert';
import * as fsExtra from 'fs-extra';
import * as path from 'path';
-import type { CodeAction, CompletionItem, Position, Range, SignatureInformation, Location, DocumentSymbol, CancellationToken, DocumentLink } from 'vscode-languageserver';
+import type { CodeAction, CompletionItem, Position, Range, SignatureInformation, Location, LocationLink, DocumentSymbol, CancellationToken, DocumentLink } from 'vscode-languageserver';
import { CancellationTokenSource, CompletionItemKind } from 'vscode-languageserver';
import type { BsConfig, FinalizedBsConfig } from './BsConfig';
import { Scope } from './Scope';
import { DiagnosticMessages } from './DiagnosticMessages';
import { BrsFile } from './files/BrsFile';
import { XmlFile } from './files/XmlFile';
-import type { BsDiagnostic, File, FileReference, FileObj, BscFile, SemanticToken, AfterFileTranspileEvent, FileLink, ProvideHoverEvent, ProvideCompletionsEvent, Hover, ProvideDefinitionEvent, ProvideReferencesEvent, ProvideDocumentSymbolsEvent, ProvideWorkspaceSymbolsEvent } from './interfaces';
+import type { BsDiagnostic, File, FileReference, FileObj, BscFile, SemanticToken, AfterFileTranspileEvent, FileLink, ProvideHoverEvent, ProvideCompletionsEvent, Hover, ProvideDefinitionEvent, ProvideReferencesEvent, ProvideDocumentSymbolsEvent, ProvideWorkspaceSymbolsEvent, ProvideDocumentLinksEvent } from './interfaces';
import { standardizePath as s, util } from './util';
import { XmlScope } from './XmlScope';
import { DiagnosticFilterer } from './DiagnosticFilterer';
@@ -1001,7 +1001,7 @@ export class Program {
* Given a position in a file, if the position is sitting on some type of identifier,
* go to the definition of that identifier (where this thing was first defined)
*/
- public getDefinition(srcPath: string, position: Position): Location[] {
+ public getDefinition(srcPath: string, position: Position): Array {
let file = this.getFile(srcPath);
if (!file) {
return [];
@@ -1017,29 +1017,56 @@ export class Program {
this.plugins.emit('beforeProvideDefinition', event);
this.plugins.emit('provideDefinition', event);
this.plugins.emit('afterProvideDefinition', event);
+
+ // If a document link's range contains the cursor position, wrap the definitions as
+ // LocationLink objects with originSelectionRange so VS Code underlines the full link range
+ // (e.g. the entire URI path in a script tag attribute) on Ctrl+hover rather than per-word.
+ if (event.definitions.length > 0) {
+ const documentLinks = this.getDocumentLinks(srcPath);
+ const matchingLink = documentLinks.find(link => util.rangeContains(link.range, position));
+ if (matchingLink) {
+ return event.definitions.map(def => {
+ if (isLocationLinkDef(def)) {
+ //already a LocationLink; overwrite originSelectionRange with the full link range
+ return {
+ originSelectionRange: matchingLink.range,
+ targetUri: def.targetUri,
+ targetRange: def.targetRange,
+ targetSelectionRange: def.targetSelectionRange
+ } as LocationLink;
+ }
+ return {
+ originSelectionRange: matchingLink.range,
+ targetUri: def.uri,
+ targetRange: def.range,
+ targetSelectionRange: def.range
+ } as LocationLink;
+ });
+ }
+ }
+
return event.definitions;
}
/**
- * Get document links (clickable URI ranges) for the specified file.
- * This is used to make script tag URI attributes in XML files single-clickable links.
+ * Get document links for the specified file by emitting the `provideDocumentLinks` plugin event.
+ * Document links are used to resolve `originSelectionRange` in go-to-definition results so that the
+ * full link range (e.g. an entire script tag URI path) is highlighted on Ctrl+hover.
*/
public getDocumentLinks(srcPath: string): DocumentLink[] {
const file = this.getFile(srcPath);
- if (!isXmlFile(file)) {
+ if (!file) {
return [];
}
- const links: DocumentLink[] = [];
- for (const scriptImport of file.scriptTagImports) {
- if (scriptImport.filePathRange) {
- const scriptFile = this.getFile(scriptImport.pkgPath);
- links.push({
- range: scriptImport.filePathRange,
- target: scriptFile ? util.pathToUri(scriptFile.srcPath) : undefined
- });
- }
- }
- return links;
+ const event: ProvideDocumentLinksEvent = {
+ program: this,
+ file: file,
+ documentLinks: []
+ };
+ this.plugins.emit('beforeProvideDocumentLinks', event);
+ this.plugins.emit('provideDocumentLinks', event);
+ this.plugins.emit('afterProvideDocumentLinks', event);
+ return event.documentLinks;
}
public getHover(srcPath: string, position: Position): Hover[] {
@@ -1565,6 +1592,17 @@ export class Program {
}
}
+/**
+ * Type guard that returns true if the given `Location | LocationLink` is a `LocationLink`.
+ * A `LocationLink` has `targetUri`, `targetRange`, and `targetSelectionRange` properties,
+ * whereas a `Location` has `uri` and `range`.
+ */
+function isLocationLinkDef(def: Location | LocationLink): def is LocationLink {
+ return typeof (def as LocationLink).targetUri === 'string' &&
+ (def as LocationLink).targetRange !== undefined &&
+ (def as LocationLink).targetSelectionRange !== undefined;
+}
+
export interface FileTranspileResult {
srcPath: string;
pkgPath: string;
diff --git a/src/Scope.ts b/src/Scope.ts
index be25f0033..22182b159 100644
--- a/src/Scope.ts
+++ b/src/Scope.ts
@@ -1,4 +1,4 @@
-import type { CompletionItem, Position, Range, Location } from 'vscode-languageserver';
+import type { CompletionItem, Position, Range, Location, LocationLink } from 'vscode-languageserver';
import * as path from 'path';
import { CompletionItemKind } from 'vscode-languageserver';
import chalk from 'chalk';
@@ -1261,7 +1261,7 @@ export class Scope {
* Get the definition (where was this thing first defined) of the symbol under the position
* @deprecated use `DefinitionProvider.process()`
*/
- public getDefinition(file: BscFile, position: Position): Location[] {
+ public getDefinition(file: BscFile, position: Position): Array {
// Overridden in XMLScope. Brs files use implementation in BrsFile
return [];
}
diff --git a/src/XmlScope.spec.ts b/src/XmlScope.spec.ts
index 885cbf79b..4097cc411 100644
--- a/src/XmlScope.spec.ts
+++ b/src/XmlScope.spec.ts
@@ -73,7 +73,7 @@ describe('XmlScope', () => {
`);
const definition = program.getDefinition(childXmlFile.srcPath, Position.create(1, 48));
expect(definition).to.be.lengthOf(1);
- expect(definition[0].uri).to.equal(util.pathToUri(parentXmlFile.srcPath));
+ expect((definition[0] as any).uri).to.equal(util.pathToUri(parentXmlFile.srcPath));
});
});
diff --git a/src/XmlScope.ts b/src/XmlScope.ts
index 81180ba40..435b16311 100644
--- a/src/XmlScope.ts
+++ b/src/XmlScope.ts
@@ -1,4 +1,4 @@
-import type { Location, Position } from 'vscode-languageserver';
+import type { Location, LocationLink, Position } from 'vscode-languageserver';
import { Scope } from './Scope';
import { DiagnosticMessages } from './DiagnosticMessages';
import type { XmlFile } from './files/XmlFile';
@@ -169,7 +169,7 @@ export class XmlScope extends Scope {
* Get the definition (where was this thing first defined) of the symbol under the position
* @deprecated use `DefinitionProvider.process()`
*/
- public getDefinition(file: BscFile, position: Position): Location[] {
+ public getDefinition(file: BscFile, position: Position): Array {
return new DefinitionProvider({
program: this.program,
file: file,
diff --git a/src/bscPlugin/BscPlugin.ts b/src/bscPlugin/BscPlugin.ts
index 5922a0599..93728b801 100644
--- a/src/bscPlugin/BscPlugin.ts
+++ b/src/bscPlugin/BscPlugin.ts
@@ -1,9 +1,10 @@
import { isBrsFile, isXmlFile } from '../astUtils/reflection';
-import type { BeforeFileTranspileEvent, Plugin, OnFileValidateEvent, OnGetCodeActionsEvent, ProvideHoverEvent, OnGetSemanticTokensEvent, OnScopeValidateEvent, ProvideCompletionsEvent, ProvideDefinitionEvent, ProvideReferencesEvent, ProvideDocumentSymbolsEvent, ProvideWorkspaceSymbolsEvent } from '../interfaces';
+import type { BeforeFileTranspileEvent, Plugin, OnFileValidateEvent, OnGetCodeActionsEvent, ProvideHoverEvent, OnGetSemanticTokensEvent, OnScopeValidateEvent, ProvideCompletionsEvent, ProvideDefinitionEvent, ProvideReferencesEvent, ProvideDocumentSymbolsEvent, ProvideWorkspaceSymbolsEvent, ProvideDocumentLinksEvent } from '../interfaces';
import type { Program } from '../Program';
import { CodeActionsProcessor } from './codeActions/CodeActionsProcessor';
import { CompletionsProcessor } from './completions/CompletionsProcessor';
import { DefinitionProvider } from './definition/DefinitionProvider';
+import { DocumentLinksProvider } from './documentLinks/DocumentLinksProvider';
import { DocumentSymbolProcessor } from './symbols/DocumentSymbolProcessor';
import { HoverProcessor } from './hover/HoverProcessor';
import { ReferencesProvider } from './references/ReferencesProvider';
@@ -42,6 +43,10 @@ export class BscPlugin implements Plugin {
new DefinitionProvider(event).process();
}
+ public provideDocumentLinks(event: ProvideDocumentLinksEvent) {
+ new DocumentLinksProvider(event).process();
+ }
+
public provideReferences(event: ProvideReferencesEvent) {
new ReferencesProvider(event).process();
}
diff --git a/src/bscPlugin/definition/DefinitionProvider.spec.ts b/src/bscPlugin/definition/DefinitionProvider.spec.ts
index 12033d83e..a2ee0dec2 100644
--- a/src/bscPlugin/definition/DefinitionProvider.spec.ts
+++ b/src/bscPlugin/definition/DefinitionProvider.spec.ts
@@ -207,12 +207,13 @@ describe('DefinitionProvider', () => {
`);
// Line 2 (0-indexed): ` `
// The uri value range starts at the opening `"` for `pkg:/components/MainScene.brs`
- expect(
- program.getDefinition(xmlFile.srcPath, util.createPosition(2, 60))
- ).to.eql([{
- uri: URI.file(brsFile.srcPath).toString(),
- range: util.createRange(0, 0, 0, 0)
- }]);
+ const result = program.getDefinition(xmlFile.srcPath, util.createPosition(2, 60));
+ expect(result).to.be.lengthOf(1);
+ expect(result[0]).to.include({
+ targetUri: URI.file(brsFile.srcPath).toString()
+ });
+ // originSelectionRange should cover the full URI value (the entire filePathRange)
+ expect((result[0] as any).originSelectionRange).to.exist;
});
it('handles script tag uri go-to-definition with relative path', () => {
@@ -227,12 +228,12 @@ describe('DefinitionProvider', () => {
`);
// Line 2 (0-indexed): ` `
// The uri value range starts at the opening `"` for `MainScene.brs`
- expect(
- program.getDefinition(xmlFile.srcPath, util.createPosition(2, 54))
- ).to.eql([{
- uri: URI.file(brsFile.srcPath).toString(),
- range: util.createRange(0, 0, 0, 0)
- }]);
+ const result = program.getDefinition(xmlFile.srcPath, util.createPosition(2, 54));
+ expect(result).to.be.lengthOf(1);
+ expect(result[0]).to.include({
+ targetUri: URI.file(brsFile.srcPath).toString()
+ });
+ expect((result[0] as any).originSelectionRange).to.exist;
});
it('returns empty array when script tag uri file is not found', () => {
@@ -246,56 +247,4 @@ describe('DefinitionProvider', () => {
program.getDefinition(xmlFile.srcPath, util.createPosition(2, 60))
).to.eql([]);
});
-
- describe('getDocumentLinks', () => {
- it('returns document links for script tag uris', () => {
- const brsFile = program.setFile('components/MainScene.brs', `
- sub main()
- end sub
- `);
- const xmlFile = program.setFile('components/MainScene.xml', `
-
-
-
- `);
- const links = program.getDocumentLinks(xmlFile.srcPath);
- expect(links).to.be.lengthOf(1);
- expect(links[0].target).to.equal(URI.file(brsFile.srcPath).toString());
- });
-
- it('returns document link with undefined target when script file is not found', () => {
- const xmlFile = program.setFile('components/MainScene.xml', `
-
-
-
- `);
- const links = program.getDocumentLinks(xmlFile.srcPath);
- expect(links).to.be.lengthOf(1);
- expect(links[0].target).to.be.undefined;
- });
-
- it('returns empty array for non-xml files', () => {
- const brsFile = program.setFile('source/main.brs', `
- sub main()
- end sub
- `);
- const links = program.getDocumentLinks(brsFile.srcPath);
- expect(links).to.eql([]);
- });
-
- it('returns multiple links for multiple script tags', () => {
- const brsFile1 = program.setFile('components/MainScene.brs', ``);
- const brsFile2 = program.setFile('components/Helpers.brs', ``);
- const xmlFile = program.setFile('components/MainScene.xml', `
-
-
-
-
- `);
- const links = program.getDocumentLinks(xmlFile.srcPath);
- expect(links).to.be.lengthOf(2);
- expect(links[0].target).to.equal(URI.file(brsFile1.srcPath).toString());
- expect(links[1].target).to.equal(URI.file(brsFile2.srcPath).toString());
- });
- });
});
diff --git a/src/bscPlugin/definition/DefinitionProvider.ts b/src/bscPlugin/definition/DefinitionProvider.ts
index 0860d4f4f..5728d39bc 100644
--- a/src/bscPlugin/definition/DefinitionProvider.ts
+++ b/src/bscPlugin/definition/DefinitionProvider.ts
@@ -2,7 +2,7 @@ import { isBrsFile, isClassStatement, isDottedGetExpression, isImportStatement,
import type { BrsFile } from '../../files/BrsFile';
import type { ProvideDefinitionEvent } from '../../interfaces';
import { TokenKind } from '../../lexer/TokenKind';
-import type { Location } from 'vscode-languageserver-protocol';
+import type { Location, LocationLink } from 'vscode-languageserver-protocol';
import type { ClassStatement, FunctionStatement, NamespaceStatement } from '../../parser/Statement';
import { ParseMode } from '../../parser/Parser';
import util from '../../util';
@@ -16,7 +16,7 @@ export class DefinitionProvider {
private event: ProvideDefinitionEvent
) { }
- public process(): Location[] {
+ public process(): Array {
if (isBrsFile(this.event.file)) {
this.brsFileGetDefinition(this.event.file);
} else if (isXmlFile(this.event.file)) {
diff --git a/src/bscPlugin/documentLinks/DocumentLinksProvider.spec.ts b/src/bscPlugin/documentLinks/DocumentLinksProvider.spec.ts
new file mode 100644
index 000000000..2335ae2b7
--- /dev/null
+++ b/src/bscPlugin/documentLinks/DocumentLinksProvider.spec.ts
@@ -0,0 +1,97 @@
+import { expect } from '../../chai-config.spec';
+import { Program } from '../../Program';
+import { standardizePath as s } from '../../util';
+import { URI } from 'vscode-uri';
+let rootDir = s`${process.cwd()}/rootDir`;
+import { createSandbox } from 'sinon';
+const sinon = createSandbox();
+
+describe('DocumentLinksProvider', () => {
+ let program: Program;
+ beforeEach(() => {
+ program = new Program({
+ rootDir: rootDir
+ });
+ sinon.restore();
+ });
+
+ afterEach(() => {
+ program.dispose();
+ sinon.restore();
+ });
+
+ it('returns document links for pkg:/ script tag uris', () => {
+ const brsFile = program.setFile('components/MainScene.brs', `
+ sub main()
+ end sub
+ `);
+ const xmlFile = program.setFile('components/MainScene.xml', `
+
+
+
+ `);
+ const links = program.getDocumentLinks(xmlFile.srcPath);
+ expect(links).to.be.lengthOf(1);
+ expect(links[0].target).to.equal(URI.file(brsFile.srcPath).toString());
+ expect(links[0].range).to.exist;
+ });
+
+ it('returns document links for relative script tag uris', () => {
+ const brsFile1 = program.setFile('components/MainScene.brs', ``);
+ const brsFile2 = program.setFile('components/Helpers.brs', ``);
+ const xmlFile = program.setFile('components/MainScene.xml', `
+
+
+
+
+ `);
+ const links = program.getDocumentLinks(xmlFile.srcPath);
+ expect(links).to.be.lengthOf(2);
+ expect(links[0].target).to.equal(URI.file(brsFile1.srcPath).toString());
+ expect(links[1].target).to.equal(URI.file(brsFile2.srcPath).toString());
+ });
+
+ it('returns document link with undefined target when script file is not found', () => {
+ const xmlFile = program.setFile('components/MainScene.xml', `
+
+
+
+ `);
+ const links = program.getDocumentLinks(xmlFile.srcPath);
+ expect(links).to.be.lengthOf(1);
+ expect(links[0].target).to.be.undefined;
+ });
+
+ it('returns empty array for non-xml files', () => {
+ const brsFile = program.setFile('source/main.brs', `
+ sub main()
+ end sub
+ `);
+ const links = program.getDocumentLinks(brsFile.srcPath);
+ expect(links).to.eql([]);
+ });
+
+ it('returns empty array for unknown file', () => {
+ const links = program.getDocumentLinks(`${rootDir}/does-not-exist.xml`);
+ expect(links).to.eql([]);
+ });
+
+ it('allows plugins to contribute additional document links', () => {
+ const xmlFile = program.setFile('components/MainScene.xml', `
+
+
+ `);
+ const plugin = {
+ name: 'test-plugin',
+ provideDocumentLinks: (event: any) => {
+ event.documentLinks.push({
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
+ target: 'file:///some/file.brs'
+ });
+ }
+ };
+ program.plugins.add(plugin as any);
+ const links = program.getDocumentLinks(xmlFile.srcPath);
+ expect(links.some(l => l.target === 'file:///some/file.brs')).to.be.true;
+ });
+});
diff --git a/src/bscPlugin/documentLinks/DocumentLinksProvider.ts b/src/bscPlugin/documentLinks/DocumentLinksProvider.ts
new file mode 100644
index 000000000..94add86ec
--- /dev/null
+++ b/src/bscPlugin/documentLinks/DocumentLinksProvider.ts
@@ -0,0 +1,28 @@
+import { isXmlFile } from '../../astUtils/reflection';
+import type { ProvideDocumentLinksEvent } from '../../interfaces';
+import type { XmlFile } from '../../files/XmlFile';
+import util from '../../util';
+
+export class DocumentLinksProvider {
+ constructor(
+ private event: ProvideDocumentLinksEvent
+ ) { }
+
+ public process(): void {
+ if (isXmlFile(this.event.file)) {
+ this.xmlFileGetDocumentLinks(this.event.file);
+ }
+ }
+
+ private xmlFileGetDocumentLinks(file: XmlFile): void {
+ for (const scriptImport of file.scriptTagImports) {
+ if (scriptImport.filePathRange) {
+ const scriptFile = this.event.program.getFile(scriptImport.pkgPath);
+ this.event.documentLinks.push({
+ range: scriptImport.filePathRange,
+ target: scriptFile ? util.pathToUri(scriptFile.srcPath) : undefined
+ });
+ }
+ }
+ }
+}
diff --git a/src/files/BrsFile.ts b/src/files/BrsFile.ts
index d8b86cf45..4efc38082 100644
--- a/src/files/BrsFile.ts
+++ b/src/files/BrsFile.ts
@@ -1,6 +1,6 @@
import type { CodeWithSourceMap } from 'source-map';
import { SourceNode } from 'source-map';
-import type { CompletionItem, Position, Location, Diagnostic } from 'vscode-languageserver';
+import type { CompletionItem, Position, Location, LocationLink, Diagnostic } from 'vscode-languageserver';
import { CancellationTokenSource } from 'vscode-languageserver';
import { CompletionItemKind, TextEdit } from 'vscode-languageserver';
import chalk from 'chalk';
@@ -1309,7 +1309,7 @@ export class BrsFile {
* go to the definition of that identifier (where this thing was first defined)
* @deprecated use `DefinitionProvider.process()` instead
*/
- public getDefinition(position: Position): Location[] {
+ public getDefinition(position: Position): Array {
return new DefinitionProvider({
program: this.program,
file: this,
diff --git a/src/interfaces.ts b/src/interfaces.ts
index 79137bb1d..82c085e78 100644
--- a/src/interfaces.ts
+++ b/src/interfaces.ts
@@ -1,4 +1,4 @@
-import type { Range, Diagnostic, CodeAction, Position, CompletionItem, Location, DocumentSymbol, WorkspaceSymbol, Disposable, FileChangeType } from 'vscode-languageserver-protocol';
+import type { Range, Diagnostic, CodeAction, Position, CompletionItem, Location, LocationLink, DocumentSymbol, WorkspaceSymbol, Disposable, FileChangeType, DocumentLink } from 'vscode-languageserver-protocol';
import type { Scope } from './Scope';
import type { BrsFile } from './files/BrsFile';
import type { XmlFile } from './files/XmlFile';
@@ -277,6 +277,24 @@ export interface Plugin {
afterProvideReferences?(event: AfterProvideReferencesEvent): any;
+ /**
+ * Called before the `provideDocumentLinks` hook
+ */
+ beforeProvideDocumentLinks?(event: BeforeProvideDocumentLinksEvent): any;
+ /**
+ * Provide `DocumentLink`s for the given file.
+ * Document links are used to determine `originSelectionRange` in go-to-definition results so the full
+ * link range (e.g. an entire script tag URI path) is highlighted on Ctrl+hover.
+ * @param event
+ */
+ provideDocumentLinks?(event: ProvideDocumentLinksEvent): any;
+ /**
+ * Called after `provideDocumentLinks`. Use this if you want to intercept or sanitize the document links data provided by bsc or other plugins
+ * @param event
+ */
+ afterProvideDocumentLinks?(event: AfterProvideDocumentLinksEvent): any;
+
+
/**
* Called before the `provideDocumentSymbols` hook
*/
@@ -394,9 +412,12 @@ export interface ProvideDefinitionEvent {
*/
position: Position;
/**
- * The list of locations for where the item at the file and position was defined
+ * The list of locations for where the item at the file and position was defined.
+ * Plugins may push either `Location` or `LocationLink` objects.
+ * When a `LocationLink` is pushed, VS Code will use `originSelectionRange` to highlight
+ * the source range of the link (e.g. the full URI of a script tag attribute).
*/
- definitions: Location[];
+ definitions: Array;
}
export type BeforeProvideDefinitionEvent = ProvideDefinitionEvent;
export type AfterProvideDefinitionEvent = ProvideDefinitionEvent;
@@ -434,6 +455,23 @@ export interface ProvideDocumentSymbolsEvent {
export type BeforeProvideDocumentSymbolsEvent = ProvideDocumentSymbolsEvent;
export type AfterProvideDocumentSymbolsEvent = ProvideDocumentSymbolsEvent;
+export interface ProvideDocumentLinksEvent {
+ program: Program;
+ /**
+ * The file for which document links are being requested
+ */
+ file: TFile;
+ /**
+ * The list of document links contributed by plugins.
+ * Document links are used to resolve `originSelectionRange` in go-to-definition results,
+ * so that the full link range (e.g. an entire script tag URI path) is highlighted on Ctrl+hover
+ * rather than just the word under the cursor.
+ */
+ documentLinks: DocumentLink[];
+}
+export type BeforeProvideDocumentLinksEvent = ProvideDocumentLinksEvent;
+export type AfterProvideDocumentLinksEvent = ProvideDocumentLinksEvent;
+
export interface ProvideWorkspaceSymbolsEvent {
program: Program;
diff --git a/src/lsp/LspProject.ts b/src/lsp/LspProject.ts
index 4857de723..6bc6d633f 100644
--- a/src/lsp/LspProject.ts
+++ b/src/lsp/LspProject.ts
@@ -1,4 +1,4 @@
-import type { Diagnostic, Position, Range, Location, DocumentSymbol, WorkspaceSymbol, CodeAction, CompletionList, DocumentLink } from 'vscode-languageserver-protocol';
+import type { Diagnostic, Position, Range, Location, LocationLink, DocumentSymbol, WorkspaceSymbol, CodeAction, CompletionList } from 'vscode-languageserver-protocol';
import type { Hover, MaybePromise, SemanticToken } from '../interfaces';
import type { DocumentAction, DocumentActionWithStatus } from './DocumentManager';
import type { FileTranspileResult, SignatureInfoObj } from '../Program';
@@ -114,7 +114,7 @@ export interface LspProject {
* Get the locations where the symbol at the specified position is defined
* @param options the file path and position to get the definition for
*/
- getDefinition(options: { srcPath: string; position: Position }): MaybePromise;
+ getDefinition(options: { srcPath: string; position: Position }): MaybePromise>;
/**
* Get the locations where the symbol at the specified position is defined
@@ -142,11 +142,6 @@ export interface LspProject {
*/
getCodeActions(options: { srcPath: string; range: Range }): Promise;
- /**
- * Get document links (clickable URI ranges) for the specified file
- */
- getDocumentLinks(options: { srcPath: string }): MaybePromise;
-
/**
* Get the completions for the specified file and position
*/
diff --git a/src/lsp/Project.ts b/src/lsp/Project.ts
index 911886833..1cd6663a6 100644
--- a/src/lsp/Project.ts
+++ b/src/lsp/Project.ts
@@ -9,7 +9,7 @@ import { URI } from 'vscode-uri';
import { Deferred } from '../deferred';
import type { StandardizedFileEntry } from 'roku-deploy';
import { rokuDeploy } from 'roku-deploy';
-import type { DocumentSymbol, Position, Range, Location, WorkspaceSymbol } from 'vscode-languageserver-protocol';
+import type { DocumentSymbol, Position, Range, Location, LocationLink, WorkspaceSymbol } from 'vscode-languageserver-protocol';
import { CompletionList } from 'vscode-languageserver-protocol';
import { CancellationTokenSource } from 'vscode-languageserver-protocol';
import type { DocumentAction, DocumentActionWithStatus } from './DocumentManager';
@@ -383,7 +383,7 @@ export class Project implements LspProject {
}
}
- public async getDefinition(options: { srcPath: string; position: Position }): Promise {
+ public async getDefinition(options: { srcPath: string; position: Position }): Promise> {
await this.onIdle();
if (this.builder.program.hasFile(options.srcPath)) {
return this.builder.program.getDefinition(options.srcPath, options.position);
@@ -432,13 +432,6 @@ export class Project implements LspProject {
}
}
- public async getDocumentLinks(options: { srcPath: string }) {
- await this.onIdle();
- if (this.builder.program.hasFile(options.srcPath)) {
- return this.builder.program.getDocumentLinks(options.srcPath);
- }
- }
-
public async getCompletions(options: { srcPath: string; position: Position }): Promise {
await this.onIdle();
diff --git a/src/lsp/ProjectManager.ts b/src/lsp/ProjectManager.ts
index fcf074703..14ea11a34 100644
--- a/src/lsp/ProjectManager.ts
+++ b/src/lsp/ProjectManager.ts
@@ -6,7 +6,7 @@ import type { LspDiagnostic, LspProject, ProjectConfig } from './LspProject';
import { Project } from './Project';
import { WorkerThreadProject } from './worker/WorkerThreadProject';
import { FileChangeType } from 'vscode-languageserver-protocol';
-import type { Hover, Position, Range, Location, SignatureHelp, DocumentSymbol, SymbolInformation, WorkspaceSymbol, CompletionList, CancellationToken, DocumentLink } from 'vscode-languageserver-protocol';
+import type { Hover, Position, Range, Location, LocationLink, SignatureHelp, DocumentSymbol, SymbolInformation, WorkspaceSymbol, CompletionList, CancellationToken } from 'vscode-languageserver-protocol';
import { Deferred } from '../deferred';
import type { DocumentActionWithStatus, FlushEvent } from './DocumentManager';
import { DocumentManager } from './DocumentManager';
@@ -554,7 +554,7 @@ export class ProjectManager {
* @returns a list of locations where the symbol under the position is defined in the project
*/
@TrackBusyStatus
- public async getDefinition(options: { srcPath: string; position: Position }): Promise {
+ public async getDefinition(options: { srcPath: string; position: Position }): Promise> {
//wait for all pending syncs to finish
await this.onIdle();
@@ -668,20 +668,6 @@ export class ProjectManager {
return result;
}
- @TrackBusyStatus
- public async getDocumentLinks(options: { srcPath: string }): Promise {
- //wait for all pending syncs to finish
- await this.onIdle();
-
- //Ask every project for document links, keep whichever one responds first that has a valid response
- let result = await util.promiseRaceMatch(
- this.projects.map(x => x.getDocumentLinks(options)),
- //keep the first non-falsey result
- (result) => !!result
- );
- return result ?? [];
- }
-
/**
* Scan a given workspace for all `bsconfig.json` files. If at least one is found, then only folders who have bsconfig.json are returned.
* If none are found, then the workspaceFolder itself is treated as a project
diff --git a/src/lsp/worker/WorkerThreadProject.ts b/src/lsp/worker/WorkerThreadProject.ts
index a1b313513..675d0559f 100644
--- a/src/lsp/worker/WorkerThreadProject.ts
+++ b/src/lsp/worker/WorkerThreadProject.ts
@@ -10,7 +10,7 @@ import type { Hover, MaybePromise, SemanticToken } from '../../interfaces';
import type { DocumentAction, DocumentActionWithStatus } from '../DocumentManager';
import { Deferred } from '../../deferred';
import type { FileTranspileResult, SignatureInfoObj } from '../../Program';
-import type { Position, Range, Location, DocumentSymbol, WorkspaceSymbol, CodeAction, CompletionList, DocumentLink } from 'vscode-languageserver-protocol';
+import type { Position, Range, Location, LocationLink, DocumentSymbol, WorkspaceSymbol, CodeAction, CompletionList } from 'vscode-languageserver-protocol';
import type { Logger } from '../../logging';
import { createLogger } from '../../logging';
import * as fsExtra from 'fs-extra';
@@ -220,8 +220,8 @@ export class WorkerThreadProject implements LspProject {
return this.sendStandardRequest('getHover', options);
}
- public async getDefinition(options: { srcPath: string; position: Position }): Promise {
- return this.sendStandardRequest('getDefinition', options);
+ public async getDefinition(options: { srcPath: string; position: Position }): Promise> {
+ return this.sendStandardRequest>('getDefinition', options);
}
public async getSignatureHelp(options: { srcPath: string; position: Position }): Promise {
@@ -244,10 +244,6 @@ export class WorkerThreadProject implements LspProject {
return this.sendStandardRequest('getCodeActions', options);
}
- public async getDocumentLinks(options: { srcPath: string }): Promise {
- return this.sendStandardRequest('getDocumentLinks', options);
- }
-
public async getCompletions(options: { srcPath: string; position: Position }): Promise {
return this.sendStandardRequest('getCompletions', options);
}
From ca84ce36591d421744628c26d3ae1cfc3b98f3c4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 20 Mar 2026 13:34:21 +0000
Subject: [PATCH 05/10] =?UTF-8?q?Remove=20DocumentLinksProvider=20?=
=?UTF-8?q?=E2=80=94=20DefinitionProvider=20now=20returns=20LocationLink?=
=?UTF-8?q?=20directly=20with=20originSelectionRange?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: TwitchBronBron <2544493+TwitchBronBron@users.noreply.github.com>
---
src/Program.ts | 63 +-----------
src/bscPlugin/BscPlugin.ts | 7 +-
.../definition/DefinitionProvider.ts | 8 +-
.../DocumentLinksProvider.spec.ts | 97 -------------------
.../documentLinks/DocumentLinksProvider.ts | 28 ------
src/interfaces.ts | 37 +------
6 files changed, 10 insertions(+), 230 deletions(-)
delete mode 100644 src/bscPlugin/documentLinks/DocumentLinksProvider.spec.ts
delete mode 100644 src/bscPlugin/documentLinks/DocumentLinksProvider.ts
diff --git a/src/Program.ts b/src/Program.ts
index 17ed59f7b..d6c97cf55 100644
--- a/src/Program.ts
+++ b/src/Program.ts
@@ -1,14 +1,14 @@
import * as assert from 'assert';
import * as fsExtra from 'fs-extra';
import * as path from 'path';
-import type { CodeAction, CompletionItem, Position, Range, SignatureInformation, Location, LocationLink, DocumentSymbol, CancellationToken, DocumentLink } from 'vscode-languageserver';
+import type { CodeAction, CompletionItem, Position, Range, SignatureInformation, Location, LocationLink, DocumentSymbol, CancellationToken } from 'vscode-languageserver';
import { CancellationTokenSource, CompletionItemKind } from 'vscode-languageserver';
import type { BsConfig, FinalizedBsConfig } from './BsConfig';
import { Scope } from './Scope';
import { DiagnosticMessages } from './DiagnosticMessages';
import { BrsFile } from './files/BrsFile';
import { XmlFile } from './files/XmlFile';
-import type { BsDiagnostic, File, FileReference, FileObj, BscFile, SemanticToken, AfterFileTranspileEvent, FileLink, ProvideHoverEvent, ProvideCompletionsEvent, Hover, ProvideDefinitionEvent, ProvideReferencesEvent, ProvideDocumentSymbolsEvent, ProvideWorkspaceSymbolsEvent, ProvideDocumentLinksEvent } from './interfaces';
+import type { BsDiagnostic, File, FileReference, FileObj, BscFile, SemanticToken, AfterFileTranspileEvent, FileLink, ProvideHoverEvent, ProvideCompletionsEvent, Hover, ProvideDefinitionEvent, ProvideReferencesEvent, ProvideDocumentSymbolsEvent, ProvideWorkspaceSymbolsEvent } from './interfaces';
import { standardizePath as s, util } from './util';
import { XmlScope } from './XmlScope';
import { DiagnosticFilterer } from './DiagnosticFilterer';
@@ -1018,57 +1018,9 @@ export class Program {
this.plugins.emit('provideDefinition', event);
this.plugins.emit('afterProvideDefinition', event);
- // If a document link's range contains the cursor position, wrap the definitions as
- // LocationLink objects with originSelectionRange so VS Code underlines the full link range
- // (e.g. the entire URI path in a script tag attribute) on Ctrl+hover rather than per-word.
- if (event.definitions.length > 0) {
- const documentLinks = this.getDocumentLinks(srcPath);
- const matchingLink = documentLinks.find(link => util.rangeContains(link.range, position));
- if (matchingLink) {
- return event.definitions.map(def => {
- if (isLocationLinkDef(def)) {
- //already a LocationLink; overwrite originSelectionRange with the full link range
- return {
- originSelectionRange: matchingLink.range,
- targetUri: def.targetUri,
- targetRange: def.targetRange,
- targetSelectionRange: def.targetSelectionRange
- } as LocationLink;
- }
- return {
- originSelectionRange: matchingLink.range,
- targetUri: def.uri,
- targetRange: def.range,
- targetSelectionRange: def.range
- } as LocationLink;
- });
- }
- }
-
return event.definitions;
}
- /**
- * Get document links for the specified file by emitting the `provideDocumentLinks` plugin event.
- * Document links are used to resolve `originSelectionRange` in go-to-definition results so that the
- * full link range (e.g. an entire script tag URI path) is highlighted on Ctrl+hover.
- */
- public getDocumentLinks(srcPath: string): DocumentLink[] {
- const file = this.getFile(srcPath);
- if (!file) {
- return [];
- }
- const event: ProvideDocumentLinksEvent = {
- program: this,
- file: file,
- documentLinks: []
- };
- this.plugins.emit('beforeProvideDocumentLinks', event);
- this.plugins.emit('provideDocumentLinks', event);
- this.plugins.emit('afterProvideDocumentLinks', event);
- return event.documentLinks;
- }
-
public getHover(srcPath: string, position: Position): Hover[] {
let file = this.getFile(srcPath);
let result: Hover[];
@@ -1592,17 +1544,6 @@ export class Program {
}
}
-/**
- * Type guard that returns true if the given `Location | LocationLink` is a `LocationLink`.
- * A `LocationLink` has `targetUri`, `targetRange`, and `targetSelectionRange` properties,
- * whereas a `Location` has `uri` and `range`.
- */
-function isLocationLinkDef(def: Location | LocationLink): def is LocationLink {
- return typeof (def as LocationLink).targetUri === 'string' &&
- (def as LocationLink).targetRange !== undefined &&
- (def as LocationLink).targetSelectionRange !== undefined;
-}
-
export interface FileTranspileResult {
srcPath: string;
pkgPath: string;
diff --git a/src/bscPlugin/BscPlugin.ts b/src/bscPlugin/BscPlugin.ts
index 93728b801..5922a0599 100644
--- a/src/bscPlugin/BscPlugin.ts
+++ b/src/bscPlugin/BscPlugin.ts
@@ -1,10 +1,9 @@
import { isBrsFile, isXmlFile } from '../astUtils/reflection';
-import type { BeforeFileTranspileEvent, Plugin, OnFileValidateEvent, OnGetCodeActionsEvent, ProvideHoverEvent, OnGetSemanticTokensEvent, OnScopeValidateEvent, ProvideCompletionsEvent, ProvideDefinitionEvent, ProvideReferencesEvent, ProvideDocumentSymbolsEvent, ProvideWorkspaceSymbolsEvent, ProvideDocumentLinksEvent } from '../interfaces';
+import type { BeforeFileTranspileEvent, Plugin, OnFileValidateEvent, OnGetCodeActionsEvent, ProvideHoverEvent, OnGetSemanticTokensEvent, OnScopeValidateEvent, ProvideCompletionsEvent, ProvideDefinitionEvent, ProvideReferencesEvent, ProvideDocumentSymbolsEvent, ProvideWorkspaceSymbolsEvent } from '../interfaces';
import type { Program } from '../Program';
import { CodeActionsProcessor } from './codeActions/CodeActionsProcessor';
import { CompletionsProcessor } from './completions/CompletionsProcessor';
import { DefinitionProvider } from './definition/DefinitionProvider';
-import { DocumentLinksProvider } from './documentLinks/DocumentLinksProvider';
import { DocumentSymbolProcessor } from './symbols/DocumentSymbolProcessor';
import { HoverProcessor } from './hover/HoverProcessor';
import { ReferencesProvider } from './references/ReferencesProvider';
@@ -43,10 +42,6 @@ export class BscPlugin implements Plugin {
new DefinitionProvider(event).process();
}
- public provideDocumentLinks(event: ProvideDocumentLinksEvent) {
- new DocumentLinksProvider(event).process();
- }
-
public provideReferences(event: ProvideReferencesEvent) {
new ReferencesProvider(event).process();
}
diff --git a/src/bscPlugin/definition/DefinitionProvider.ts b/src/bscPlugin/definition/DefinitionProvider.ts
index 5728d39bc..22a9492e9 100644
--- a/src/bscPlugin/definition/DefinitionProvider.ts
+++ b/src/bscPlugin/definition/DefinitionProvider.ts
@@ -264,9 +264,13 @@ export class DefinitionProvider {
if (scriptImport.filePathRange && util.rangeContains(scriptImport.filePathRange, this.event.position)) {
const scriptFile = this.event.program.getFile(scriptImport.pkgPath);
if (scriptFile) {
+ // Return a LocationLink so VS Code uses `originSelectionRange` to underline the
+ // entire URI path as a single unit on Ctrl+hover (rather than per path-segment).
this.event.definitions.push({
- range: util.createRange(0, 0, 0, 0),
- uri: util.pathToUri(scriptFile.srcPath)
+ originSelectionRange: scriptImport.filePathRange,
+ targetUri: util.pathToUri(scriptFile.srcPath),
+ targetRange: util.createRange(0, 0, 0, 0),
+ targetSelectionRange: util.createRange(0, 0, 0, 0)
});
}
break;
diff --git a/src/bscPlugin/documentLinks/DocumentLinksProvider.spec.ts b/src/bscPlugin/documentLinks/DocumentLinksProvider.spec.ts
deleted file mode 100644
index 2335ae2b7..000000000
--- a/src/bscPlugin/documentLinks/DocumentLinksProvider.spec.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { expect } from '../../chai-config.spec';
-import { Program } from '../../Program';
-import { standardizePath as s } from '../../util';
-import { URI } from 'vscode-uri';
-let rootDir = s`${process.cwd()}/rootDir`;
-import { createSandbox } from 'sinon';
-const sinon = createSandbox();
-
-describe('DocumentLinksProvider', () => {
- let program: Program;
- beforeEach(() => {
- program = new Program({
- rootDir: rootDir
- });
- sinon.restore();
- });
-
- afterEach(() => {
- program.dispose();
- sinon.restore();
- });
-
- it('returns document links for pkg:/ script tag uris', () => {
- const brsFile = program.setFile('components/MainScene.brs', `
- sub main()
- end sub
- `);
- const xmlFile = program.setFile('components/MainScene.xml', `
-
-
-
- `);
- const links = program.getDocumentLinks(xmlFile.srcPath);
- expect(links).to.be.lengthOf(1);
- expect(links[0].target).to.equal(URI.file(brsFile.srcPath).toString());
- expect(links[0].range).to.exist;
- });
-
- it('returns document links for relative script tag uris', () => {
- const brsFile1 = program.setFile('components/MainScene.brs', ``);
- const brsFile2 = program.setFile('components/Helpers.brs', ``);
- const xmlFile = program.setFile('components/MainScene.xml', `
-
-
-
-
- `);
- const links = program.getDocumentLinks(xmlFile.srcPath);
- expect(links).to.be.lengthOf(2);
- expect(links[0].target).to.equal(URI.file(brsFile1.srcPath).toString());
- expect(links[1].target).to.equal(URI.file(brsFile2.srcPath).toString());
- });
-
- it('returns document link with undefined target when script file is not found', () => {
- const xmlFile = program.setFile('components/MainScene.xml', `
-
-
-
- `);
- const links = program.getDocumentLinks(xmlFile.srcPath);
- expect(links).to.be.lengthOf(1);
- expect(links[0].target).to.be.undefined;
- });
-
- it('returns empty array for non-xml files', () => {
- const brsFile = program.setFile('source/main.brs', `
- sub main()
- end sub
- `);
- const links = program.getDocumentLinks(brsFile.srcPath);
- expect(links).to.eql([]);
- });
-
- it('returns empty array for unknown file', () => {
- const links = program.getDocumentLinks(`${rootDir}/does-not-exist.xml`);
- expect(links).to.eql([]);
- });
-
- it('allows plugins to contribute additional document links', () => {
- const xmlFile = program.setFile('components/MainScene.xml', `
-
-
- `);
- const plugin = {
- name: 'test-plugin',
- provideDocumentLinks: (event: any) => {
- event.documentLinks.push({
- range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
- target: 'file:///some/file.brs'
- });
- }
- };
- program.plugins.add(plugin as any);
- const links = program.getDocumentLinks(xmlFile.srcPath);
- expect(links.some(l => l.target === 'file:///some/file.brs')).to.be.true;
- });
-});
diff --git a/src/bscPlugin/documentLinks/DocumentLinksProvider.ts b/src/bscPlugin/documentLinks/DocumentLinksProvider.ts
deleted file mode 100644
index 94add86ec..000000000
--- a/src/bscPlugin/documentLinks/DocumentLinksProvider.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { isXmlFile } from '../../astUtils/reflection';
-import type { ProvideDocumentLinksEvent } from '../../interfaces';
-import type { XmlFile } from '../../files/XmlFile';
-import util from '../../util';
-
-export class DocumentLinksProvider {
- constructor(
- private event: ProvideDocumentLinksEvent
- ) { }
-
- public process(): void {
- if (isXmlFile(this.event.file)) {
- this.xmlFileGetDocumentLinks(this.event.file);
- }
- }
-
- private xmlFileGetDocumentLinks(file: XmlFile): void {
- for (const scriptImport of file.scriptTagImports) {
- if (scriptImport.filePathRange) {
- const scriptFile = this.event.program.getFile(scriptImport.pkgPath);
- this.event.documentLinks.push({
- range: scriptImport.filePathRange,
- target: scriptFile ? util.pathToUri(scriptFile.srcPath) : undefined
- });
- }
- }
- }
-}
diff --git a/src/interfaces.ts b/src/interfaces.ts
index 82c085e78..e773fbe62 100644
--- a/src/interfaces.ts
+++ b/src/interfaces.ts
@@ -1,4 +1,4 @@
-import type { Range, Diagnostic, CodeAction, Position, CompletionItem, Location, LocationLink, DocumentSymbol, WorkspaceSymbol, Disposable, FileChangeType, DocumentLink } from 'vscode-languageserver-protocol';
+import type { Range, Diagnostic, CodeAction, Position, CompletionItem, Location, LocationLink, DocumentSymbol, WorkspaceSymbol, Disposable, FileChangeType } from 'vscode-languageserver-protocol';
import type { Scope } from './Scope';
import type { BrsFile } from './files/BrsFile';
import type { XmlFile } from './files/XmlFile';
@@ -277,24 +277,6 @@ export interface Plugin {
afterProvideReferences?(event: AfterProvideReferencesEvent): any;
- /**
- * Called before the `provideDocumentLinks` hook
- */
- beforeProvideDocumentLinks?(event: BeforeProvideDocumentLinksEvent): any;
- /**
- * Provide `DocumentLink`s for the given file.
- * Document links are used to determine `originSelectionRange` in go-to-definition results so the full
- * link range (e.g. an entire script tag URI path) is highlighted on Ctrl+hover.
- * @param event
- */
- provideDocumentLinks?(event: ProvideDocumentLinksEvent): any;
- /**
- * Called after `provideDocumentLinks`. Use this if you want to intercept or sanitize the document links data provided by bsc or other plugins
- * @param event
- */
- afterProvideDocumentLinks?(event: AfterProvideDocumentLinksEvent): any;
-
-
/**
* Called before the `provideDocumentSymbols` hook
*/
@@ -455,23 +437,6 @@ export interface ProvideDocumentSymbolsEvent {
export type BeforeProvideDocumentSymbolsEvent = ProvideDocumentSymbolsEvent;
export type AfterProvideDocumentSymbolsEvent = ProvideDocumentSymbolsEvent;
-export interface ProvideDocumentLinksEvent {
- program: Program;
- /**
- * The file for which document links are being requested
- */
- file: TFile;
- /**
- * The list of document links contributed by plugins.
- * Document links are used to resolve `originSelectionRange` in go-to-definition results,
- * so that the full link range (e.g. an entire script tag URI path) is highlighted on Ctrl+hover
- * rather than just the word under the cursor.
- */
- documentLinks: DocumentLink[];
-}
-export type BeforeProvideDocumentLinksEvent = ProvideDocumentLinksEvent;
-export type AfterProvideDocumentLinksEvent = ProvideDocumentLinksEvent;
-
export interface ProvideWorkspaceSymbolsEvent {
program: Program;
From 4b0704cf5ce8c1cf43e7c27bafb17ff03c263387 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 20 Mar 2026 16:45:54 +0000
Subject: [PATCH 06/10] Add generic file path go-to-definition for BRS/BS
string literals and XML attribute values
Co-authored-by: TwitchBronBron <2544493+TwitchBronBron@users.noreply.github.com>
Agent-Logs-Url: https://github.com/rokucommunity/brighterscript/sessions/d4d2b757-f74c-4e91-9fc0-3b8bcfa0128b
---
.../definition/DefinitionProvider.spec.ts | 140 +++++++++++++++++
.../definition/DefinitionProvider.ts | 142 ++++++++++++++++--
2 files changed, 267 insertions(+), 15 deletions(-)
diff --git a/src/bscPlugin/definition/DefinitionProvider.spec.ts b/src/bscPlugin/definition/DefinitionProvider.spec.ts
index a2ee0dec2..1c7ba8edc 100644
--- a/src/bscPlugin/definition/DefinitionProvider.spec.ts
+++ b/src/bscPlugin/definition/DefinitionProvider.spec.ts
@@ -247,4 +247,144 @@ describe('DefinitionProvider', () => {
program.getDefinition(xmlFile.srcPath, util.createPosition(2, 60))
).to.eql([]);
});
+
+ it('handles pkg:/ string literal in brs assignment (e.g. poster.uri)', () => {
+ const targetFile = program.setFile('source/assets.brs', `
+ function getAsset()
+ end function
+ `);
+ const main = program.setFile('source/main.brs', `
+ sub main()
+ poster = CreateObject("roSGNode", "Poster")
+ poster.uri = "pkg:/source/assets.brs"
+ end sub
+ `);
+ // Line 3 (0-indexed): ` poster.uri = "pkg:/source/assets.brs"`
+ // "pkg:/source/assets.brs" starts at col 29 (opening ") + content at col 30
+ const result = program.getDefinition(main.srcPath, util.createPosition(3, 35));
+ expect(result).to.be.lengthOf(1);
+ expect(result[0]).to.include({
+ targetUri: URI.file(targetFile.srcPath).toString()
+ });
+ expect((result[0] as any).originSelectionRange).to.exist;
+ });
+
+ it('handles relative (./) string literal in brs assignment', () => {
+ const targetFile = program.setFile('source/utils.brs', `
+ function helper()
+ end function
+ `);
+ const main = program.setFile('source/main.brs', `
+ sub main()
+ m.uri = "./utils.brs"
+ end sub
+ `);
+ // Line 2 (0-indexed): ` m.uri = "./utils.brs"`
+ // "./utils.brs" starts at col 24 (opening ") + content at col 25
+ const result = program.getDefinition(main.srcPath, util.createPosition(2, 27));
+ expect(result).to.be.lengthOf(1);
+ expect(result[0]).to.include({
+ targetUri: URI.file(targetFile.srcPath).toString()
+ });
+ expect((result[0] as any).originSelectionRange).to.exist;
+ });
+
+ it('handles relative (../) string literal in brs assignment', () => {
+ const targetFile = program.setFile('source/shared.brs', `
+ function shared()
+ end function
+ `);
+ const main = program.setFile('source/sub/main.brs', `
+ sub main()
+ m.uri = "../shared.brs"
+ end sub
+ `);
+ // Line 2 (0-indexed): ` m.uri = "../shared.brs"`
+ // "../shared.brs" starts at col 24 (opening ") + content at col 25
+ const result = program.getDefinition(main.srcPath, util.createPosition(2, 27));
+ expect(result).to.be.lengthOf(1);
+ expect(result[0]).to.include({
+ targetUri: URI.file(targetFile.srcPath).toString()
+ });
+ expect((result[0] as any).originSelectionRange).to.exist;
+ });
+
+ it('does not treat arbitrary brs string literals as file paths', () => {
+ const main = program.setFile('source/main.brs', `
+ sub main()
+ print "hello world"
+ end sub
+ `);
+ // "hello world" does not start with a path prefix — must not resolve as a file
+ // Line 2: ` print "hello world"`
+ // "hello world" starts at col 22 (opening ") + content at col 23
+ const result = program.getDefinition(main.srcPath, util.createPosition(2, 25));
+ expect(result).to.eql([]);
+ });
+
+ it('handles xml child node uri attribute go-to-definition', () => {
+ const targetFile = program.setFile('components/utils.brs', `
+ function helper()
+ end function
+ `);
+ const xmlFile = program.setFile('components/MainScene.xml', `
+
+
+
+
+
+ `);
+ // Line 3 (0-indexed): ` `
+ // Attribute value "pkg:/components/utils.brs" starts (after opening ") at col 33
+ const result = program.getDefinition(xmlFile.srcPath, util.createPosition(3, 36));
+ expect(result).to.be.lengthOf(1);
+ expect(result[0]).to.include({
+ targetUri: URI.file(targetFile.srcPath).toString()
+ });
+ expect((result[0] as any).originSelectionRange).to.exist;
+ });
+
+ it('handles xml child node backgroundURI attribute go-to-definition', () => {
+ const targetFile = program.setFile('components/bg.brs', `
+ function getBg()
+ end function
+ `);
+ const xmlFile = program.setFile('components/MainScene.xml', `
+
+
+
+
+
+ `);
+ // Line 3 (0-indexed): ` `
+ // backgroundURI value starts (after opening ") at col 42
+ const result = program.getDefinition(xmlFile.srcPath, util.createPosition(3, 45));
+ expect(result).to.be.lengthOf(1);
+ expect(result[0]).to.include({
+ targetUri: URI.file(targetFile.srcPath).toString()
+ });
+ expect((result[0] as any).originSelectionRange).to.exist;
+ });
+
+ it('handles xml child node relative uri attribute go-to-definition', () => {
+ const targetFile = program.setFile('components/utils.brs', `
+ function helper()
+ end function
+ `);
+ const xmlFile = program.setFile('components/MainScene.xml', `
+
+
+
+
+
+ `);
+ // Line 3 (0-indexed): ` `
+ // Attribute value "./utils.brs" starts (after opening ") at col 33
+ const result = program.getDefinition(xmlFile.srcPath, util.createPosition(3, 35));
+ expect(result).to.be.lengthOf(1);
+ expect(result[0]).to.include({
+ targetUri: URI.file(targetFile.srcPath).toString()
+ });
+ expect((result[0] as any).originSelectionRange).to.exist;
+ });
});
diff --git a/src/bscPlugin/definition/DefinitionProvider.ts b/src/bscPlugin/definition/DefinitionProvider.ts
index 22a9492e9..e323cdaf6 100644
--- a/src/bscPlugin/definition/DefinitionProvider.ts
+++ b/src/bscPlugin/definition/DefinitionProvider.ts
@@ -2,7 +2,7 @@ import { isBrsFile, isClassStatement, isDottedGetExpression, isImportStatement,
import type { BrsFile } from '../../files/BrsFile';
import type { ProvideDefinitionEvent } from '../../interfaces';
import { TokenKind } from '../../lexer/TokenKind';
-import type { Location, LocationLink } from 'vscode-languageserver-protocol';
+import type { Location, LocationLink, Range } from 'vscode-languageserver-protocol';
import type { ClassStatement, FunctionStatement, NamespaceStatement } from '../../parser/Statement';
import { ParseMode } from '../../parser/Parser';
import util from '../../util';
@@ -10,6 +10,7 @@ import { URI } from 'vscode-uri';
import { WalkMode, createVisitor } from '../../astUtils/visitors';
import type { Token } from '../../lexer/Token';
import type { XmlFile } from '../../files/XmlFile';
+import type { SGAttribute, SGNode } from '../../parser/SGTypes';
export class DefinitionProvider {
constructor(
@@ -25,6 +26,38 @@ export class DefinitionProvider {
return this.event.definitions;
}
+ /**
+ * Given a string that may be a file path and an origin range, try to resolve the path to a
+ * file in the program. Returns a LocationLink (with originSelectionRange set so VS Code
+ * underlines the whole path as one unit on Ctrl+hover) when the file is found, or null.
+ * Only considers strings that start with a recognised path prefix:
+ * pkg:/, libpkg:/, ./, ../
+ */
+ private tryGetFilePathLocationLink(pathStr: string, containingFilePkgPath: string, originRange: Range): LocationLink | null {
+ if (!pathStr) {
+ return null;
+ }
+ // Require a recognised path prefix so we don't accidentally match arbitrary strings
+ // (e.g. component names in createObject calls).
+ if (!/^(?:pkg:|libpkg:|\.\/|\.\.\/)/i.test(pathStr)) {
+ return null;
+ }
+ const pkgPath = util.getPkgPathFromTarget(containingFilePkgPath, pathStr);
+ if (!pkgPath) {
+ return null;
+ }
+ const targetFile = this.event.program.getFile(pkgPath);
+ if (!targetFile) {
+ return null;
+ }
+ return {
+ originSelectionRange: originRange,
+ targetUri: util.pathToUri(targetFile.srcPath),
+ targetRange: util.createRange(0, 0, 0, 0),
+ targetSelectionRange: util.createRange(0, 0, 0, 0)
+ };
+ }
+
/**
* For a position in a BrsFile, get the location where the token at that position was defined
*/
@@ -142,6 +175,24 @@ export class DefinitionProvider {
}
}
+ // Generic file path detection: if the string literal looks like a file path
+ // (pkg:/, libpkg:/, ./, ../) resolve it and navigate to that file.
+ const pathStr = token.text.replace(/^"|"$/g, '');
+ const link = this.tryGetFilePathLocationLink(
+ pathStr,
+ file.pkgPath,
+ util.createRange(
+ token.range.start.line,
+ token.range.start.character + 1,
+ token.range.end.line,
+ token.range.end.character - 1
+ )
+ );
+ if (link) {
+ this.event.definitions.push(link);
+ return;
+ }
+
// We need to strip off the quotes but only if present
const startIndex = textToSearchFor.startsWith('"') ? 1 : 0;
@@ -257,24 +308,85 @@ export class DefinitionProvider {
range: util.createRange(0, 0, 0, 0),
uri: util.pathToUri(file.parentComponent.srcPath)
});
+ return;
+ }
+
+ // Generic XML attribute value path resolution.
+ // Walk the entire component tree (component attributes, script tags, children nodes,
+ // customization nodes) and return a definition for the first attribute value that
+ // looks like a file path and resolves to a known file.
+ const component = file.ast?.component;
+ if (!component) {
+ return;
+ }
+
+ // Component-level attributes (e.g. extends="...")
+ if (this.xmlGetFilePathDefinitionFromAttributes(component.attributes, file.pkgPath)) {
+ return;
+ }
+ //