diff --git a/src/LanguageServer.ts b/src/LanguageServer.ts
index d05dd090c..7e7f82b95 100644
--- a/src/LanguageServer.ts
+++ b/src/LanguageServer.ts
@@ -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,
diff --git a/src/Program.spec.ts b/src/Program.spec.ts
index fc038d6b5..99dd0bfa1 100644
--- a/src/Program.spec.ts
+++ b/src/Program.spec.ts
@@ -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`
+
+
+ `);
+ 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`
+
+
+ `);
+ 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`
+
+
+
+
+
+ `);
+ 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()
diff --git a/src/Program.ts b/src/Program.ts
index 0fa40f31c..a5c271aa8 100644
--- a/src/Program.ts
+++ b/src/Program.ts
@@ -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;
+
export interface SourceObj {
/**
* @deprecated use `srcPath` instead
@@ -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();
+ 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();
+ const visitedNodeNames = new Set();
+
+ 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
*/
@@ -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;
+}
diff --git a/src/files/XmlFile.spec.ts b/src/files/XmlFile.spec.ts
index 44d1700ee..8ea04b7e5 100644
--- a/src/files/XmlFile.spec.ts
+++ b/src/files/XmlFile.spec.ts
@@ -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`
+
+
+ <
+
+
+ `);
+ expect(isXmlFile(file)).to.be.true;
+ });
+
it('allows modifying the parsed XML model', () => {
const expected = 'OtherName';
program.plugins.add({
@@ -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 ', () => {
program.setFile('components/component1.brs', ``);
let xmlFile = program.setFile('components/component1.xml', trim`
@@ -394,8 +406,113 @@ describe('XmlFile', () => {
`);
+ //at the component root (not inside ) 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`
+
+
+ `);
+ const xmlFile = program.setFile('components/main.xml', trim`
+
+
+ <
+
+
+ `);
+ //position the caret right after the lone `<` inside
+ 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 / completions inside (not nodes)', () => {
+ const xmlFile = program.setFile('components/main.xml', trim`
+
+
+ <
+
+
+ `);
+ 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 , not inside
+ expect(labels).not.to.include('Label');
+ });
+
+ it('provides attribute completions inside a tag', () => {
+ const xmlFile = program.setFile('components/main.xml', trim`
+
+
+
+
+
+ `);
+ const labels = xmlFile.getCompletions(positionAfter(xmlFile.fileContents, ' 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 tag', () => {
+ const xmlFile = program.setFile('components/main.xml', trim`
+
+
+
+
+
+ `);
+ const labels = xmlFile.getCompletions(positionAfter(xmlFile.fileContents, ' x.label);
+ expect(labels).to.eql(['name']);
+ });
+
+ it('provides field completions inside an open element tag', () => {
+ const xmlFile = program.setFile('components/main.xml', trim`
+
+
+
+
+ `);
+ const completions = xmlFile.getCompletions(positionAfter(xmlFile.fileContents, '