From 2cf4b037a44c68cc16ced497e04ea70996230e8f Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Tue, 7 Jul 2026 12:32:33 -0300 Subject: [PATCH 1/4] Add SceneGraph XML element and attribute-name completions Provide IntelliSense in .xml component files, driven by the built-in roku-types node metadata and project components. - Program: getSceneGraphNodeNames/getSceneGraphNodeFields (resolves the extends chain across built-in nodes and project components), hasSceneGraphNode, getSceneGraphNode - XmlFile.getCompletions: element (tag name) completions after `<` and in element content, and field (attribute) completions inside a start tag; adds getTokenAt for token-at-position lookups - LanguageServer: add `<` as a completion trigger character - SGParser: skip malformed child elements with no tag name instead of throwing (a lone `<` while typing) - roku-types: add `extends` to SGNodeData --- src/LanguageServer.ts | 4 +- src/Program.spec.ts | 46 ++++++++++++ src/Program.ts | 140 ++++++++++++++++++++++++++++++++++++ src/files/XmlFile.spec.ts | 81 ++++++++++++++++++++- src/files/XmlFile.ts | 146 +++++++++++++++++++++++++++++++++++++- src/parser/SGParser.ts | 16 ++++- src/roku-types/index.ts | 4 ++ 7 files changed, 428 insertions(+), 9 deletions(-) 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..5cfe116b4 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('provides xml element completions, never brightscript scope completions', () => { program.setFile('components/component1.brs', ``); let xmlFile = program.setFile('components/component1.xml', trim` @@ -394,7 +406,72 @@ describe('XmlFile', () => { `); - expect(program.getCompletions(xmlFile.srcPath, Position.create(1, 1))).to.be.empty; + const completions = program.getCompletions(xmlFile.srcPath, Position.create(1, 1)); + //every completion is an xml element (node) completion, not a brightscript scope symbol + expect(completions).not.to.be.empty; + expect(completions.every(x => x.kind === CompletionItemKind.Class)).to.be.true; + }); + + /** + * 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 field completions inside an open element tag', () => { + const xmlFile = program.setFile('components/main.xml', trim` + + + + + `); + const completions = xmlFile.getCompletions(positionAfter(xmlFile.fileContents, '