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
4 changes: 2 additions & 2 deletions src/LanguageServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,8 @@ export class LanguageServer {
// Tell the client that the server supports code completion
completionProvider: {
resolveProvider: false,
//anytime the user types a period, auto-show the completion results
triggerCharacters: ['.'],
//`.` auto-shows brightscript completions; `<` auto-shows xml element completions
triggerCharacters: ['.', '<'],
allCommitCharacters: ['.', '@']
},
documentSymbolProvider: true,
Expand Down
46 changes: 46 additions & 0 deletions src/Program.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,52 @@ describe('Program', () => {
program.dispose();
});

describe('scenegraph node metadata', () => {
it('getSceneGraphNodeNames includes built-in nodes and project components', () => {
program.setFile('components/widget.xml', trim`
<component name="Widget" extends="Group">
</component>
`);
const names = program.getSceneGraphNodeNames();
expect(names).to.include('Label');
expect(names).to.include('Widget');
});

it('hasSceneGraphNode recognizes built-in nodes and project components (case-insensitive)', () => {
program.setFile('components/widget.xml', trim`
<component name="Widget" extends="Group">
</component>
`);
expect(program.hasSceneGraphNode('label')).to.be.true;
expect(program.hasSceneGraphNode('widget')).to.be.true;
expect(program.hasSceneGraphNode('NotARealNode')).to.be.false;
});

it('getSceneGraphNodeFields walks the extends chain of a built-in node', () => {
//Label extends LabelBase extends ... eventually Node; `id` comes from Node
const fieldNames = program.getSceneGraphNodeFields('Label').map(x => x.name);
expect(fieldNames).to.include('text');
expect(fieldNames).to.include('id');
});

it('getSceneGraphNodeFields walks from a project component into its built-in parent', () => {
program.setFile('components/widget.xml', trim`
<component name="Widget" extends="Group">
<interface>
<field id="caption" type="string" />
</interface>
</component>
`);
const fields = program.getSceneGraphNodeFields('Widget');
const own = fields.find(x => x.name === 'caption');
const inherited = fields.find(x => x.name === 'visible');
//own field from the component's interface
expect(own?.origin).to.equal('own');
//inherited field from the built-in Group (via Node)
expect(inherited?.origin).to.equal('inherited');
});
});

it('does not throw exception after calling validate() after dispose()', () => {
program.setFile('source/themes/alpha.bs', `
sub main()
Expand Down
140 changes: 140 additions & 0 deletions src/Program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,18 @@ import { SignatureHelpUtil } from './bscPlugin/SignatureHelpUtil';
import { DiagnosticSeverityAdjuster } from './DiagnosticSeverityAdjuster';
import { Sequencer } from './common/Sequencer';
import { Deferred } from './deferred';
import { nodes as builtInSceneGraphNodeData } from './roku-types';
import type { SGNodeData } from './roku-types';

const startOfSourcePkgPath = `source${path.sep}`;
const bslibNonAliasedRokuModulesPkgPath = s`source/roku_modules/rokucommunity_bslib/bslib.brs`;
const bslibAliasedRokuModulesPkgPath = s`source/roku_modules/bslib/bslib.brs`;

/**
* The built-in Roku SceneGraph nodes, keyed by their lower-case name
*/
const builtInSceneGraphNodes = builtInSceneGraphNodeData as unknown as Record<string, SGNodeData>;

export interface SourceObj {
/**
* @deprecated use `srcPath` instead
Expand Down Expand Up @@ -490,6 +497,113 @@ export class Program {
return this.getComponent(componentName)?.scope;
}

/**
* Get the names of all known SceneGraph nodes: the built-in Roku nodes plus every component
* defined in this program. Names keep their original casing and are deduplicated by lower-case name.
*/
public getSceneGraphNodeNames(): string[] {
const namesByLowerName = new Map<string, string>();
for (const node of Object.values(builtInSceneGraphNodes)) {
namesByLowerName.set(node.name.toLowerCase(), node.name);
}
for (const componentName in this.components) {
const displayName = this.components[componentName][0]?.file.componentName?.text;
if (displayName) {
namesByLowerName.set(displayName.toLowerCase(), displayName);
}
}
return [...namesByLowerName.values()];
}

/**
* Determine whether a SceneGraph node with the given name exists, either as a built-in Roku node
* or as a component defined in this program.
*/
public hasSceneGraphNode(nodeName: string): boolean {
if (!nodeName) {
return false;
}
return !!builtInSceneGraphNodes[nodeName.toLowerCase()] || !!this.getComponent(nodeName);
}

/**
* Get the built-in Roku node data and/or the project component file backing a SceneGraph node name.
* Returns `undefined` when no node or component matches.
*/
public getSceneGraphNode(nodeName: string): SceneGraphNodeLookup | undefined {
if (!nodeName) {
return undefined;
}
const builtInNode = builtInSceneGraphNodes[nodeName.toLowerCase()];
const componentFile = this.getComponent(nodeName)?.file;
if (!builtInNode && !componentFile) {
return undefined;
}
return { builtInNode: builtInNode, componentFile: componentFile };
}

/**
* Get every field available on a SceneGraph node, walking the full `extends` chain across both
* built-in Roku nodes and project components. Fields declared closer to the node take precedence
* over inherited fields with the same name.
*/
public getSceneGraphNodeFields(nodeName: string): ResolvedSceneGraphField[] {
const fieldsByLowerName = new Map<string, ResolvedSceneGraphField>();
const visitedNodeNames = new Set<string>();

const addField = (field: ResolvedSceneGraphField) => {
if (!field.name) {
return;
}
const lowerName = field.name.toLowerCase();
//the first definition we encounter is the closest one, so it wins
if (!fieldsByLowerName.has(lowerName)) {
fieldsByLowerName.set(lowerName, field);
}
};

const walk = (currentNodeName: string, origin: SceneGraphFieldOrigin) => {
const lowerName = currentNodeName?.toLowerCase();
if (!lowerName || visitedNodeNames.has(lowerName)) {
return;
}
visitedNodeNames.add(lowerName);

//prefer a project component (a project component can shadow a built-in of the same name)
const component = this.getComponent(currentNodeName);
if (component) {
for (const field of component.file.ast.component?.api?.fields ?? []) {
addField({ name: field.id, type: field.type, default: field.value, origin: origin });
}
const parentName = component.file.ast.component?.extends;
if (parentName) {
walk(parentName, 'inherited');
}
return;
}

const builtInNode = builtInSceneGraphNodes[lowerName];
if (builtInNode) {
for (const field of builtInNode.fields ?? []) {
addField({
name: field.name,
type: field.type,
default: field.default,
description: field.description,
accessPermission: field.accessPermission,
origin: origin
});
}
if (builtInNode.extends?.name) {
walk(builtInNode.extends.name, 'inherited');
}
}
};

walk(nodeName, 'own');
return [...fieldsByLowerName.values()];
}

/**
* Update internal maps with this file reference
*/
Expand Down Expand Up @@ -1930,3 +2044,29 @@ export interface FileTranspileResult {
map: SourceMapGenerator;
typedef: string;
}

/**
* Where a resolved SceneGraph field was declared, relative to the node it was resolved for:
* `own` = declared on the node itself, `inherited` = declared on an ancestor in the `extends` chain
*/
export type SceneGraphFieldOrigin = 'own' | 'inherited';

/**
* A field available on a SceneGraph node, resolved across the node's full `extends` chain
*/
export interface ResolvedSceneGraphField {
name: string;
type?: string;
default?: string;
description?: string;
accessPermission?: string;
origin: SceneGraphFieldOrigin;
}

/**
* The built-in Roku node data and/or project component file backing a SceneGraph node name
*/
export interface SceneGraphNodeLookup {
builtInNode?: SGNodeData;
componentFile?: XmlFile;
}
119 changes: 118 additions & 1 deletion src/files/XmlFile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ describe('XmlFile', () => {
});

describe('parse', () => {
it('does not crash on malformed child elements (e.g. a lone `<` while typing)', () => {
//this used to throw in SGParser.mapNode because the element had no tag name
file = program.setFile('components/malformed.xml', trim`
<component name="Main" extends="Group">
<children>
<
</children>
</component>
`);
expect(isXmlFile(file)).to.be.true;
});

it('allows modifying the parsed XML model', () => {
const expected = 'OtherName';
program.plugins.add({
Expand Down Expand Up @@ -384,7 +396,7 @@ describe('XmlFile', () => {
});

//TODO - refine this test once cdata scripts are supported
it('prevents scope completions entirely', () => {
it('does not provide node completions outside of <children>', () => {
program.setFile('components/component1.brs', ``);

let xmlFile = program.setFile('components/component1.xml', trim`
Expand All @@ -394,8 +406,113 @@ describe('XmlFile', () => {
</component>
`);

//at the component root (not inside <children>) we should not spew node/scope completions
expect(program.getCompletions(xmlFile.srcPath, Position.create(1, 1))).to.be.empty;
});

/**
* Find the (line, character) position immediately after the first occurrence of `marker` in the file
*/
function positionAfter(fileContents: string, marker: string): Position {
const index = fileContents.indexOf(marker);
const before = fileContents.substring(0, index + marker.length);
const lines = before.split('\n');
return Position.create(lines.length - 1, lines[lines.length - 1].length);
}

it('provides element completions for scenegraph nodes and project components after `<`', () => {
program.setFile('components/widget.xml', trim`
<component name="Widget" extends="Group">
</component>
`);
const xmlFile = program.setFile<XmlFile>('components/main.xml', trim`
<component name="Main" extends="Group">
<children>
<
</children>
</component>
`);
//position the caret right after the lone `<` inside <children>
const lines = xmlFile.fileContents.split('\n');
const lineIndex = lines.findIndex(line => line.trim() === '<');
const completions = xmlFile.getCompletions(Position.create(lineIndex, lines[lineIndex].indexOf('<') + 1));
const labels = completions.map(x => x.label);
//built-in node
expect(labels).to.include('Label');
//project component
expect(labels).to.include('Widget');
//a component can't contain itself
expect(labels).not.to.include('Main');
expect(completions.every(x => x.kind === CompletionItemKind.Class)).to.be.true;
});

it('provides <field>/<function> completions inside <interface> (not nodes)', () => {
const xmlFile = program.setFile<XmlFile>('components/main.xml', trim`
<component name="Main" extends="Group">
<interface>
<
</interface>
</component>
`);
const lines = xmlFile.fileContents.split('\n');
const lineIndex = lines.findIndex(line => line.trim() === '<');
const labels = xmlFile.getCompletions(Position.create(lineIndex, lines[lineIndex].indexOf('<') + 1)).map(x => x.label);
expect(labels).to.include.members(['field', 'function']);
//nodes/components are only valid inside <children>, not inside <interface>
expect(labels).not.to.include('Label');
});

it('provides attribute completions inside a <field> tag', () => {
const xmlFile = program.setFile<XmlFile>('components/main.xml', trim`
<component name="Main" extends="Group">
<interface>
<field id="thing" >
</interface>
</component>
`);
const labels = xmlFile.getCompletions(positionAfter(xmlFile.fileContents, '<field id="thing" ')).map(x => x.label);
expect(labels).to.include.members(['type', 'value', 'onChange', 'alias']);
//`id` is already present, so it should not be suggested again
expect(labels).not.to.include('id');
});

it('provides attribute completions inside a <function> tag', () => {
const xmlFile = program.setFile<XmlFile>('components/main.xml', trim`
<component name="Main" extends="Group">
<interface>
<function >
</interface>
</component>
`);
const labels = xmlFile.getCompletions(positionAfter(xmlFile.fileContents, '<function ')).map(x => x.label);
expect(labels).to.eql(['name']);
});

it('provides field completions inside an open element tag', () => {
const xmlFile = program.setFile<XmlFile>('components/main.xml', trim`
<component name="Main" extends="Group">
<children>
<Label text="hi" >
</children>
</component>
`);
const completions = xmlFile.getCompletions(positionAfter(xmlFile.fileContents, '<Label text="hi" '));
const labels = completions.map(x => x.label);
//Label has a `color` field
expect(labels).to.include('color');
//`text` is already present on the tag, so it should not be suggested again
expect(labels).not.to.include('text');
expect(completions.every(x => x.kind === CompletionItemKind.Field)).to.be.true;
});

it('getTokenAt returns the token whose range contains the position', () => {
const xmlFile = program.setFile<XmlFile>('components/main.xml', trim`
<component name="Main" extends="Group">
</component>
`);
const token = xmlFile.getTokenAt(positionAfter(xmlFile.fileContents, '<compon'));
expect(token?.image).to.equal('component');
});
});

describe('getAllDependencies', () => {
Expand Down
Loading
Loading