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 `= 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; +}