Skip to content
Open
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
75 changes: 74 additions & 1 deletion src/bscPlugin/hover/HoverProcessor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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`
<component name="Main" extends="Group">
<children>
<Label text="hi" />
</children>
</component>
`;
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`
<component name="Main" extends="Group">
<children>
<Label text="hi" />
</children>
</component>
`;
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`
<component name="Widget" extends="Group">
</component>
`);
const contents = trim`
<component name="Main" extends="Group">
<children>
<Widget />
</children>
</component>
`;
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`
<component name="Main" extends="Group">
<children>
<Label text="hi" />
</children>
</component>
`;
const file = program.setFile('components/main.xml', contents);
expect(program.getHover(file.srcPath, positionOf(contents, '"hi"'))).to.be.empty;
});
});
});
50 changes: 48 additions & 2 deletions src/bscPlugin/hover/HoverProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
}
57 changes: 56 additions & 1 deletion src/files/XmlFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand All @@ -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)
*/
Expand Down Expand Up @@ -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;
}
Loading