• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rokucommunity / brighterscript / 28897083570

07 Jul 2026 08:38PM UTC coverage: 88.401%. First build
28897083570

Pull #1743

github

web-flow
Merge 194cc75f8 into 95d7393d7
Pull Request #1743: Add hover for SceneGraph nodes and fields in xml components

9269 of 11032 branches covered (84.02%)

Branch coverage included in aggregate %.

55 of 60 new or added lines in 2 files covered. (91.67%)

11469 of 12427 relevant lines covered (92.29%)

2022.7 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.45
/src/bscPlugin/hover/HoverProcessor.ts
1
import { isBrsFile, isFunctionType, isXmlFile } from '../../astUtils/reflection';
1✔
2
import type { BrsFile } from '../../files/BrsFile';
3
import type { XmlFile } from '../../files/XmlFile';
4
import type { Hover, ProvideHoverEvent } from '../../interfaces';
5
import type { Token } from '../../lexer/Token';
6
import { TokenKind } from '../../lexer/TokenKind';
1✔
7
import { BrsTranspileState } from '../../parser/BrsTranspileState';
1✔
8
import { ParseMode } from '../../parser/Parser';
1✔
9
import util from '../../util';
1✔
10

11
export class HoverProcessor {
1✔
12
    public constructor(
13
        public event: ProvideHoverEvent
34✔
14
    ) {
15

16
    }
17

18
    public process() {
19
        let hover: Hover | undefined;
20
        if (isBrsFile(this.event.file)) {
34✔
21
            hover = this.getBrsFileHover(this.event.file);
30✔
22
        } else if (isXmlFile(this.event.file)) {
4!
23
            hover = this.getXmlFileHover(this.event.file);
4✔
24
        }
25

26
        //if we got a result, "return" it
27
        if (hover) {
34✔
28
            //assign the hover to the event
29
            this.event.hovers.push(hover);
29✔
30
        }
31
    }
32

33
    private buildContentsWithDocs(text: string, startingToken: Token) {
34
        const parts = [text];
20✔
35
        const docs = this.getTokenDocumentation((this.event.file as BrsFile).parser.tokens, startingToken);
20✔
36
        if (docs) {
20✔
37
            parts.push('***', docs);
2✔
38
        }
39
        return parts.join('\n');
20✔
40
    }
41

42
    private getBrsFileHover(file: BrsFile): Hover | undefined {
43
        const scope = this.event.scopes[0];
30✔
44
        const fence = (code: string) => util.mdFence(code, 'brightscript');
30✔
45
        //get the token at the position
46
        let token = file.getTokenAt(this.event.position);
30✔
47

48
        let hoverTokenTypes = [
30✔
49
            TokenKind.Identifier,
50
            TokenKind.Function,
51
            TokenKind.EndFunction,
52
            TokenKind.Sub,
53
            TokenKind.EndSub
54
        ];
55

56
        //throw out invalid tokens and the wrong kind of tokens
57
        if (!token || !hoverTokenTypes.includes(token.kind)) {
30✔
58
            return undefined;
4✔
59
        }
60

61
        const expression = file.getClosestExpression(this.event.position);
26✔
62
        if (expression?.range) {
26!
63
            let containingNamespace = file.getNamespaceStatementForPosition(expression.range.start)?.getName(ParseMode.BrighterScript);
26✔
64
            const fullName = util.getAllDottedGetParts(expression)?.map(x => x.text).join('.');
30✔
65

66
            //find a constant with this name
67
            const constant = scope?.getConstFileLink(fullName, containingNamespace);
26!
68
            if (constant) {
26✔
69
                const constantValue = util.sourceNodeFromTranspileResult(null, null, null, constant.item.value.transpile(new BrsTranspileState(file))).toString();
5✔
70
                return {
5✔
71
                    contents: this.buildContentsWithDocs(fence(`const ${constant.item.fullName} = ${constantValue}`), constant.item.tokens.const),
72
                    range: token.range
73
                };
74
            }
75
        }
76

77
        let lowerTokenText = token.text.toLowerCase();
21✔
78

79
        //look through local variables first
80
        {
81
            //get the function scope for this position (if exists)
82
            let functionScope = file.getFunctionScopeAtPosition(this.event.position);
21✔
83
            if (functionScope) {
21!
84
                //find any variable with this name
85
                for (const varDeclaration of functionScope.variableDeclarations) {
21✔
86
                    //we found a variable declaration with this token text!
87
                    if (varDeclaration.name.toLowerCase() === lowerTokenText) {
21✔
88
                        let typeText: string;
89
                        if (isFunctionType(varDeclaration.type)) {
6✔
90
                            typeText = varDeclaration.type.toString();
2✔
91
                        } else {
92
                            typeText = `${varDeclaration.name} as ${varDeclaration.type.toString()}`;
4✔
93
                        }
94
                        return {
6✔
95
                            range: token.range,
96
                            //append the variable name to the front for scope
97
                            contents: fence(typeText)
98
                        };
99
                    }
100
                }
101
                for (const labelStatement of functionScope.labelStatements) {
15✔
102
                    if (labelStatement.name.toLocaleLowerCase() === lowerTokenText) {
×
103
                        return {
×
104
                            range: token.range,
105
                            contents: fence(`${labelStatement.name}: label`)
106
                        };
107
                    }
108
                }
109
            }
110
        }
111

112
        //look through all callables in relevant scopes
113
        for (let scope of this.event.scopes) {
15✔
114
            let callable = scope.getCallableByName(lowerTokenText);
15✔
115
            if (callable) {
15!
116
                return {
15✔
117
                    range: token.range,
118
                    contents: this.buildContentsWithDocs(fence(callable.type.toString()), callable.functionStatement?.func?.functionType)
90✔
119
                };
120
            }
121
        }
122
    }
123

124
    /**
125
     * Combine all the documentation found before a token (i.e. comment tokens)
126
     */
127
    private getTokenDocumentation(tokens: Token[], token?: Token) {
128
        const comments = [] as Token[];
20✔
129
        if (!token) {
20✔
130
            return undefined;
3✔
131
        }
132
        const idx = tokens?.indexOf(token);
17!
133
        if (!idx || idx === -1) {
17✔
134
            return undefined;
3✔
135
        }
136
        for (let i = idx - 1; i >= 0; i--) {
14✔
137
            const token = tokens[i];
42✔
138
            //skip whitespace and newline chars
139
            if (token.kind === TokenKind.Comment) {
42✔
140
                comments.push(token);
7✔
141
            } else if (token.kind === TokenKind.Newline || token.kind === TokenKind.Whitespace) {
35✔
142
                //skip these tokens
143
                continue;
25✔
144

145
                //any other token means there are no more comments
146
            } else {
147
                break;
10✔
148
            }
149
        }
150
        if (comments.length > 0) {
14✔
151
            return comments.reverse().map(x => x.text.replace(/^('|rem)/i, '')).join('\n');
7✔
152
        }
153
    }
154

155
    private getXmlFileHover(file: XmlFile): Hover | undefined {
156
        const context = file.getNodeAndFieldAt(this.event.position);
4✔
157
        if (!context) {
4✔
158
            return undefined;
1✔
159
        }
160
        const program = file.program;
3✔
161

162
        //hovering an attribute (field) name -> show the field's type, default, and description
163
        if (context.fieldName) {
3✔
164
            const field = program.getSceneGraphNodeFields(context.nodeName).find(
1✔
165
                x => x.name.toLowerCase() === context.fieldName.toLowerCase()
16✔
166
            );
167
            if (!field) {
1!
NEW
168
                return undefined;
×
169
            }
170
            const parts = [util.mdFence(`${field.name} as ${field.type ?? 'dynamic'}`, 'brightscript')];
1!
171
            if (field.default !== undefined && field.default !== '') {
1!
NEW
172
                parts.push(`Default: \`${field.default}\``);
×
173
            }
174
            if (field.description) {
1!
175
                parts.push('***', field.description);
1✔
176
            }
177
            return { contents: parts.join('\n'), range: context.range };
1✔
178
        }
179

180
        //hovering a node's tag name -> show its description, parent, and docs link
181
        const node = program.getSceneGraphNode(context.nodeName);
2✔
182
        if (!node) {
2!
NEW
183
            return undefined;
×
184
        }
185
        const parts: string[] = [];
2✔
186
        if (node.builtInNode) {
2✔
187
            const extendsName = node.builtInNode.extends?.name;
1!
188
            parts.push(`**${node.builtInNode.name}**${extendsName ? ` extends ${extendsName}` : ''}`);
1!
189
            //the scraped description often begins with a redundant "Extends [Parent](...)" paragraph; drop it
190
            const description = node.builtInNode.description?.replace(/^Extends .*?(\n\n|$)/s, '').trim();
1!
191
            if (description) {
1!
192
                parts.push('***', description);
1✔
193
            }
194
            if (node.builtInNode.url) {
1!
195
                parts.push('', `[Roku docs](${node.builtInNode.url})`);
1✔
196
            }
197
        } else if (node.componentFile) {
1!
198
            const component = node.componentFile.ast.component;
1✔
199
            const extendsName = component?.extends;
1!
200
            parts.push(`**${component?.name ?? context.nodeName}**${extendsName ? ` extends ${extendsName}` : ''}`);
1!
201
            parts.push('', `Component defined in \`${node.componentFile.pkgPath}\``);
1✔
202
        }
203
        return { contents: parts.join('\n'), range: context.range };
2✔
204
    }
205
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc