diff --git a/src/bscPlugin/hover/HoverProcessor.spec.ts b/src/bscPlugin/hover/HoverProcessor.spec.ts
index ff0c7383e..225866074 100644
--- a/src/bscPlugin/hover/HoverProcessor.spec.ts
+++ b/src/bscPlugin/hover/HoverProcessor.spec.ts
@@ -2,9 +2,19 @@ import { expect } from '../../chai-config.spec';
import { Program } from '../../Program';
import { util } from '../../util';
import { createSandbox } from 'sinon';
-import { rootDir } from '../../testHelpers.spec';
+import { rootDir, trim } from '../../testHelpers.spec';
let sinon = createSandbox();
+/**
+ * Find the position on the first character of `marker` within `contents`
+ */
+function positionOf(contents: string, marker: string) {
+ const index = contents.indexOf(marker);
+ const before = contents.substring(0, index);
+ const lines = before.split('\n');
+ return util.createPosition(lines.length - 1, lines[lines.length - 1].length);
+}
+
const fence = (code: string) => util.mdFence(code, 'brightscript');
describe('HoverProcessor', () => {
@@ -220,4 +230,67 @@ describe('HoverProcessor', () => {
expect(hover?.contents).to.eql(fence('const name.sp.a.c.e.SOME_VALUE = true'));
});
});
+
+ describe('XmlFile', () => {
+ it('shows node docs when hovering a built-in node tag name', () => {
+ const contents = trim`
+
+
+
+
+
+ `;
+ const file = program.setFile('components/main.xml', contents);
+ const hover = program.getHover(file.srcPath, positionOf(contents, 'Label text'))[0];
+ expect(hover).to.exist;
+ expect(hover.contents).to.include('Label');
+ expect(hover.contents).to.include('extends LabelBase');
+ expect(hover.contents).to.include('Roku docs');
+ });
+
+ it('shows field docs when hovering an attribute name', () => {
+ const contents = trim`
+
+
+
+
+
+ `;
+ const file = program.setFile('components/main.xml', contents);
+ const hover = program.getHover(file.srcPath, positionOf(contents, 'text='))[0];
+ expect(hover).to.exist;
+ expect(hover.contents).to.include('text as string');
+ });
+
+ it('shows component docs when hovering a project component tag name', () => {
+ program.setFile('components/widget.xml', trim`
+
+
+ `);
+ const contents = trim`
+
+
+
+
+
+ `;
+ const file = program.setFile('components/main.xml', contents);
+ const hover = program.getHover(file.srcPath, positionOf(contents, 'Widget '))[0];
+ expect(hover).to.exist;
+ expect(hover.contents).to.include('Widget');
+ expect(hover.contents).to.include('Component defined in');
+ });
+
+ it('does not show a hover on an attribute value', () => {
+ const contents = trim`
+
+
+
+
+
+ `;
+ const file = program.setFile('components/main.xml', contents);
+ expect(program.getHover(file.srcPath, positionOf(contents, '"hi"'))).to.be.empty;
+ });
+ });
});
diff --git a/src/bscPlugin/hover/HoverProcessor.ts b/src/bscPlugin/hover/HoverProcessor.ts
index 3c2052c6d..f174004c2 100644
--- a/src/bscPlugin/hover/HoverProcessor.ts
+++ b/src/bscPlugin/hover/HoverProcessor.ts
@@ -153,7 +153,53 @@ export class HoverProcessor {
}
private getXmlFileHover(file: XmlFile): Hover | undefined {
- //TODO add xml hovers
- return undefined;
+ const context = file.getNodeAndFieldAt(this.event.position);
+ if (!context) {
+ return undefined;
+ }
+ const program = file.program;
+
+ //hovering an attribute (field) name -> show the field's type, default, and description
+ if (context.fieldName) {
+ const field = program.getSceneGraphNodeFields(context.nodeName).find(
+ x => x.name.toLowerCase() === context.fieldName.toLowerCase()
+ );
+ if (!field) {
+ return undefined;
+ }
+ const parts = [util.mdFence(`${field.name} as ${field.type ?? 'dynamic'}`, 'brightscript')];
+ if (field.default !== undefined && field.default !== '') {
+ parts.push(`Default: \`${field.default}\``);
+ }
+ if (field.description) {
+ parts.push('***', field.description);
+ }
+ return { contents: parts.join('\n'), range: context.range };
+ }
+
+ //hovering a node's tag name -> show its description, parent, and docs link
+ const node = program.getSceneGraphNode(context.nodeName);
+ if (!node) {
+ return undefined;
+ }
+ const parts: string[] = [];
+ if (node.builtInNode) {
+ const extendsName = node.builtInNode.extends?.name;
+ parts.push(`**${node.builtInNode.name}**${extendsName ? ` extends ${extendsName}` : ''}`);
+ //the scraped description often begins with a redundant "Extends [Parent](...)" paragraph; drop it
+ const description = node.builtInNode.description?.replace(/^Extends .*?(\n\n|$)/s, '').trim();
+ if (description) {
+ parts.push('***', description);
+ }
+ if (node.builtInNode.url) {
+ parts.push('', `[Roku docs](${node.builtInNode.url})`);
+ }
+ } else if (node.componentFile) {
+ const component = node.componentFile.ast.component;
+ const extendsName = component?.extends;
+ parts.push(`**${component?.name ?? context.nodeName}**${extendsName ? ` extends ${extendsName}` : ''}`);
+ parts.push('', `Component defined in \`${node.componentFile.pkgPath}\``);
+ }
+ return { contents: parts.join('\n'), range: context.range };
}
}
diff --git a/src/files/XmlFile.ts b/src/files/XmlFile.ts
index aa62d9252..fe0601755 100644
--- a/src/files/XmlFile.ts
+++ b/src/files/XmlFile.ts
@@ -566,7 +566,15 @@ export class XmlFile {
*/
public getTokenAt(position: Position): IToken | undefined {
for (const token of (this.parser.tokens ?? []) as unknown as IToken[]) {
- if (util.rangeContains(this.getTokenRange(token), position)) {
+ const startLine = (token.startLine ?? 1) - 1;
+ const startCharacter = (token.startColumn ?? 1) - 1;
+ const endLine = (token.endLine ?? 1) - 1;
+ //endColumn is the 1-based column of the last character, which is the 0-based exclusive end
+ const endCharacter = token.endColumn ?? 0;
+ //start-inclusive, end-exclusive so a caret at a token boundary belongs to a single token
+ const afterStart = position.line > startLine || (position.line === startLine && position.character >= startCharacter);
+ const beforeEnd = position.line < endLine || (position.line === endLine && position.character < endCharacter);
+ if (afterStart && beforeEnd) {
return token;
}
}
@@ -584,6 +592,43 @@ export class XmlFile {
);
}
+ /**
+ * If the position sits on a node's tag name or an attribute (field) name, describe what it points at
+ * (the enclosing node name, and the field name when on an attribute) plus the hovered token's range.
+ * Returns undefined otherwise. Used by hover.
+ */
+ public getNodeAndFieldAt(position: Position): XmlNodeContext | undefined {
+ const token = this.getTokenAt(position);
+ if (token?.tokenType.name !== XmlTokenName.name) {
+ return undefined;
+ }
+ const tokens = (this.parser.tokens ?? []) as unknown as IToken[];
+ const index = tokens.indexOf(token);
+ //walk backward to the nearest tag opener (`<` or ``)
+ let openIndex = -1;
+ for (let i = index - 1; i >= 0; i--) {
+ const tokenName = tokens[i].tokenType.name;
+ if (tokenName === XmlTokenName.open || tokenName === XmlTokenName.slashOpen) {
+ openIndex = i;
+ break;
+ }
+ //hit the end of a previous tag without finding an opener -> not on a tag/attribute name
+ if (tokenName === XmlTokenName.close || tokenName === XmlTokenName.slashClose) {
+ return undefined;
+ }
+ }
+ const tagNameToken = openIndex >= 0 ? tokens[openIndex + 1] : undefined;
+ if (tagNameToken?.tokenType.name !== XmlTokenName.name) {
+ return undefined;
+ }
+ const range = this.getTokenRange(token);
+ //the hovered token is the tag name itself -> node context; otherwise it's an attribute on that node
+ if (index === openIndex + 1) {
+ return { nodeName: tagNameToken.image, range: range };
+ }
+ return { nodeName: tagNameToken.image, fieldName: token.image, range: range };
+ }
+
/**
* Get the parent component (the component this component extends)
*/
@@ -748,3 +793,13 @@ export class XmlFile {
this.unsubscribeFromDependencyGraph?.();
}
}
+
+/**
+ * Describes what an xml position points at: a node's tag name (no `fieldName`) or an attribute (field)
+ * name on that node (`fieldName` set). `range` is the range of the hovered token.
+ */
+export interface XmlNodeContext {
+ nodeName: string;
+ fieldName?: string;
+ range: Range;
+}