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

rokucommunity / brighterscript / #12717

14 Jun 2024 08:20PM UTC coverage: 85.629% (-2.3%) from 87.936%
#12717

push

web-flow
Merge 94311dc0a into 42db50190

10808 of 13500 branches covered (80.06%)

Branch coverage included in aggregate %.

6557 of 7163 new or added lines in 96 files covered. (91.54%)

83 existing lines in 17 files now uncovered.

12270 of 13451 relevant lines covered (91.22%)

26531.66 hits per line

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

94.28
/src/parser/SGParser.ts
1
import type { AttributeCstNode, ContentCstNode, DocumentCstNode, ElementCstNode } from '@xml-tools/parser';
2
import * as parser from '@xml-tools/parser';
1✔
3
import { DiagnosticMessages } from '../DiagnosticMessages';
1✔
4
import type { Diagnostic } from 'vscode-languageserver';
5
import util from '../util';
1✔
6
import { SGAst, SGProlog, SGChildren, SGComponent, SGInterfaceField, SGInterfaceFunction, SGInterface, SGNode, SGScript, SGAttribute } from './SGTypes';
1✔
7
import type { SGElement, SGToken, SGReferences } from './SGTypes';
8
import { isSGComponent } from '../astUtils/xml';
1✔
9

10
export default class SGParser {
1✔
11

12
    /**
13
     * The AST of the XML document, not including the inline scripts
14
     */
15
    public ast: SGAst = new SGAst();
450✔
16

17
    public tokens: IToken[];
18

19
    /**
20
     * The list of diagnostics found during the parse process
21
     */
22
    public diagnostics = [] as Diagnostic[];
450✔
23

24
    /**
25
     * The options used to parse the file
26
     */
27
    public options: SGParseOptions;
28

29
    private _references: SGReferences;
30

31
    /**
32
     * These are initially extracted during parse-time, but will also be dynamically regenerated if need be.
33
     *
34
     * If a plugin modifies the AST, then the plugin should call SGAst#invalidateReferences() to force this object to refresh
35
     */
36
    get references(): SGReferences {
37
        if (this._references === undefined) {
12,609✔
38
            this.findReferences();
379✔
39
        }
40
        return this._references;
12,609✔
41
    }
42

43

44
    /**
45
     * Invalidates (clears) the references collection. This should be called anytime the AST has been manipulated.
46
     */
47
    invalidateReferences() {
48
        this._references = undefined;
×
49
    }
50

51
    /**
52
     * Walk the AST to extract references to useful bits of information
53
     */
54
    private findReferences() {
55
        this._references = this.emptySGReferences();
379✔
56

57
        const { componentElement } = this.ast;
379✔
58
        if (!componentElement) {
379✔
59
            return;
10✔
60
        }
61

62
        const nameAttr = componentElement.getAttribute('name');
369✔
63
        this._references.name = nameAttr?.tokens.value;
369✔
64
        const extendsAttr = componentElement.getAttribute('extends');
369✔
65
        if (extendsAttr?.tokens.value) {
369✔
66
            this._references.extends = extendsAttr.tokens.value;
353✔
67
        }
68

69
        for (const script of componentElement.scriptElements) {
369✔
70
            const uriAttr = script.getAttribute('uri');
227✔
71
            if (uriAttr?.tokens.value) {
227✔
72
                const uri = uriAttr.tokens.value.text;
226✔
73
                this._references.scriptTagImports.push({
226✔
74
                    filePathRange: uriAttr.tokens.value.location?.range,
678!
75
                    text: uri,
76
                    destPath: util.getPkgPathFromTarget(this.options.destPath, uri)
77
                });
78
            }
79
        }
80
    }
81

82
    private sanitizeParseOptions(options: SGParseOptions) {
83
        options ??= {
387✔
84
            destPath: undefined
85
        };
86
        options.trackLocations ??= true;
387!
87
        return options;
387✔
88
    }
89

90

91
    public parse(fileContents: string, options?: SGParseOptions) {
92
        this.options = this.sanitizeParseOptions(options);
387✔
93
        this.diagnostics = [];
387✔
94

95
        const { cst, tokenVector, lexErrors, parseErrors } = parser.parse(fileContents);
387✔
96
        this.tokens = tokenVector;
387✔
97

98
        if (lexErrors.length) {
387✔
99
            for (const err of lexErrors) {
1✔
100
                this.diagnostics.push({
1✔
101
                    ...DiagnosticMessages.xmlGenericParseError(`Syntax error: ${err.message}`),
102
                    range: util.createRange(
103
                        err.line - 1,
104
                        err.column,
105
                        err.line - 1,
106
                        err.column + err.length
107
                    )
108
                });
109
            }
110
        }
111
        if (parseErrors.length) {
387✔
112
            const err = parseErrors[0];
15✔
113
            const token = err.token;
15✔
114
            this.diagnostics.push({
15✔
115
                ...DiagnosticMessages.xmlGenericParseError(`Syntax error: ${err.message}`),
116
                range: !isNaN(token.startLine) ? this.rangeFromToken(token) : util.createRange(0, 0, 0, Number.MAX_VALUE)
15✔
117
            });
118
        }
119

120
        const { prolog, root } = this.buildAST(cst as any);
387✔
121
        if (!root) {
387✔
122
            const token1 = tokenVector[0];
12✔
123
            const token2 = tokenVector[1];
12✔
124
            //whitespace before the prolog isn't allowed by the parser
125
            if (
12✔
126
                token1?.image.trim().length === 0 &&
50✔
127
                token2?.image.trim() === '<?xml'
6!
128
            ) {
129
                this.diagnostics.push({
2✔
130
                    ...DiagnosticMessages.xmlGenericParseError('Syntax error: whitespace found before the XML prolog'),
131
                    range: this.rangeFromToken(token1)
132
                });
133
            }
134
        }
135

136
        if (isSGComponent(root)) {
387✔
137
            this.ast = new SGAst({ prologElement: prolog, rootElement: root, componentElement: root });
374✔
138
        } else {
139
            if (root) {
13✔
140
                //error: not a component
141
                this.diagnostics.push({
1✔
142
                    ...DiagnosticMessages.xmlUnexpectedTag(root.tokens.startTagName.text),
143
                    range: root.tokens.startTagName.location?.range
3!
144
                });
145
            }
146
            this.ast = new SGAst({ prologElement: prolog, rootElement: root });
13✔
147
        }
148
    }
149

150
    buildAST(cst: DocumentCstNode) {
151
        const { prolog: cstProlog, element } = cst.children;
387✔
152

153
        let prolog: SGProlog;
154
        if (cstProlog?.[0]) {
387✔
155
            const ctx = cstProlog[0].children;
236✔
156
            prolog = new SGProlog({
236✔
157
                // <?
158
                startTagOpen: this.createPartialToken(ctx.XMLDeclOpen[0], 0, 2),
159
                // xml
160
                startTagName: this.createPartialToken(ctx.XMLDeclOpen[0], 2, 3),
161
                attributes: this.mapAttributes(ctx.attribute),
162
                // ?>
163
                startTagClose: this.mapToken(ctx.SPECIAL_CLOSE[0])
164
            });
165
        }
166

167
        let root: SGElement;
168
        if (element.length > 0 && element[0]?.children?.Name) {
387!
169
            root = this.mapElement(element[0]);
375✔
170
        }
171

172
        return {
387✔
173
            prolog: prolog,
174
            root: root
175
        };
176
    }
177

178
    mapElement({ children }: ElementCstNode): SGElement {
179
        const startTagOpen = this.mapToken(children.OPEN[0]);
716✔
180
        const startTagName = this.mapToken(children.Name[0]);
716✔
181
        const attributes = this.mapAttributes(children.attribute);
716✔
182
        const startTagClose = this.mapToken((children.SLASH_CLOSE ?? children.START_CLOSE)?.[0]);
716!
183
        const endTagOpen = this.mapToken(children.SLASH_OPEN?.[0]);
716✔
184
        const endTagName = this.mapToken(children.END_NAME?.[0]);
716✔
185
        const endTagClose = this.mapToken(children.END?.[0]);
716✔
186

187
        const content = children.content?.[0];
716✔
188

189
        let constructorOptions = {
716✔
190
            startTagOpen: startTagOpen,
191
            startTagName: startTagName,
192
            attributes: attributes,
193
            startTagClose: startTagClose,
194
            elements: [],
195
            endTagOpen: endTagOpen,
196
            endTagName: endTagName,
197
            endTagClose: endTagClose
198
        };
199

200
        switch (startTagName.text) {
716✔
201
            case 'component':
716✔
202
                constructorOptions.elements = this.mapElements(content, ['interface', 'script', 'children', 'customization']);
374✔
203
                return new SGComponent(constructorOptions);
374✔
204
            case 'interface':
205
                constructorOptions.elements = this.mapElements(content, ['field', 'function']);
34✔
206
                return new SGInterface(constructorOptions);
34✔
207
            case 'field':
208
                if (this.hasElements(content)) {
29✔
209
                    this.diagnostics.push({
1✔
210
                        range: startTagName.location?.range,
3!
211
                        ...DiagnosticMessages.xmlUnexpectedChildren(startTagName.text)
212
                    });
213
                }
214
                return new SGInterfaceField(constructorOptions);
29✔
215
            case 'function':
216
                if (this.hasElements(content)) {
32!
NEW
217
                    this.diagnostics.push({
×
218
                        range: startTagName.location?.range,
×
219
                        ...DiagnosticMessages.xmlUnexpectedChildren(startTagName.text)
220
                    });
221
                }
222
                return new SGInterfaceFunction(constructorOptions);
32✔
223
            case 'script':
224
                if (this.hasElements(content)) {
232✔
225
                    this.diagnostics.push({
1✔
226
                        range: startTagName.location?.range,
3!
227
                        ...DiagnosticMessages.xmlUnexpectedChildren(startTagName.text)
228
                    });
229
                }
230
                const script = new SGScript(constructorOptions);
232✔
231
                script.cdata = this.getCdata(content);
232✔
232
                return script;
232✔
233
            case 'children':
234
                constructorOptions.elements = this.mapNodes(content);
9✔
235
                return new SGChildren(constructorOptions);
9✔
236
            default:
237
                constructorOptions.elements = this.mapNodes(content);
6✔
238
                return new SGNode(constructorOptions);
6✔
239
        }
240
    }
241

242
    mapNode({ children }: ElementCstNode): SGNode {
243
        return new SGNode({
12✔
244
            //<
245
            startTagOpen: this.mapToken(children.OPEN[0]),
246
            // TagName
247
            startTagName: this.mapToken(children.Name[0]),
248
            attributes: this.mapAttributes(children.attribute),
249
            // > or />
250
            startTagClose: this.mapToken((children.SLASH_CLOSE ?? children.START_CLOSE)[0]),
36✔
251
            elements: this.mapNodes(children.content?.[0]),
36✔
252
            // </
253
            endTagOpen: this.mapToken(children.SLASH_OPEN?.[0]),
36✔
254
            // TagName
255
            endTagName: this.mapToken(children.END_NAME?.[0]),
36✔
256
            // >
257
            endTagClose: this.mapToken(children.END?.[0])
36✔
258
        });
259
    }
260

261
    mapElements(content: ContentCstNode, allow: string[]): SGElement[] {
262
        if (!content) {
408✔
263
            return [];
6✔
264
        }
265
        const { element } = content.children;
402✔
266
        const tags: SGElement[] = [];
402✔
267
        if (element) {
402✔
268
            for (const entry of element) {
229✔
269
                const name = entry.children.Name?.[0];
342✔
270
                if (name?.image) {
342✔
271
                    if (!allow.includes(name.image)) {
341✔
272
                        //unexpected tag
273
                        this.diagnostics.push({
4✔
274
                            ...DiagnosticMessages.xmlUnexpectedTag(name.image),
275
                            range: this.rangeFromToken(name)
276
                        });
277
                    }
278
                    tags.push(this.mapElement(entry));
341✔
279
                } else {
280
                    //bad xml syntax...
281
                }
282
            }
283
        }
284
        return tags;
402✔
285
    }
286

287
    mapNodes(content: ContentCstNode): SGNode[] {
288
        if (!content) {
27✔
289
            return [];
15✔
290
        }
291
        const { element } = content.children;
12✔
292
        return element?.map(element => this.mapNode(element));
12!
293
    }
294

295
    hasElements(content: ContentCstNode): boolean {
296
        return !!content?.children.element?.length;
293✔
297
    }
298

299
    getCdata(content: ContentCstNode): SGToken {
300
        if (!content) {
232✔
301
            return undefined;
230✔
302
        }
303
        const { CData } = content.children;
2✔
304
        return this.mapToken(CData?.[0]);
2✔
305
    }
306

307
    private mapToken(token: IToken): SGToken {
308
        if (token) {
7,994✔
309
            return {
7,060✔
310
                text: token.image,
311
                //TODO should this be coerced into a uri?
312
                location: util.createLocationFromRange(this.options.srcPath, this.rangeFromToken(token))
313
            };
314
        } else {
315
            return undefined;
934✔
316
        }
317
    }
318

319
    /**
320
     * Build SGAttributes from the underlying XML parser attributes array
321
     */
322
    mapAttributes(attributes: AttributeCstNode[]) {
323
        // this is a hot function and has been optimized, so don't refactor unless you know what you're doing...
324
        const result = [] as SGAttribute[];
964✔
325
        if (attributes) {
964✔
326
            // eslint-disable-next-line @typescript-eslint/prefer-for-of
327
            for (let i = 0; i < attributes.length; i++) {
914✔
328

329
                const children = attributes[i].children;
1,695✔
330
                const attr = new SGAttribute({ key: this.mapToken(children.Name[0]) });
1,695✔
331

332
                if (children.EQUALS) {
1,695✔
333
                    attr.tokens.equals = this.mapToken(children.EQUALS[0]);
1,693✔
334
                }
335
                if (children.STRING) {
1,695✔
336
                    const valueToken = children.STRING[0];
1,693✔
337
                    attr.tokens.openingQuote = this.createPartialToken(valueToken, 0, 1);
1,693✔
338
                    attr.tokens.value = this.createPartialToken(valueToken, 1, -2);
1,693✔
339
                    attr.tokens.closingQuote = this.createPartialToken(valueToken, -1, 1);
1,693✔
340
                }
341
                result.push(attr);
1,695✔
342
            }
343
        }
344
        return result;
964✔
345
    }
346

347
    /**
348
     * Create a partial token from the given token. This only supports single-line tokens.
349
     * @param token the offset to start the new token. If negative, subtracts from token.length
350
     * @param startOffset the offset to start the new token. If negative, subtracts from token.length
351
     * @param length the length of the text to keep. If negative, subtracts from token.length
352
     */
353
    public createPartialToken(token: IToken, startOffset: number, length?: number): SGToken {
354
        if (startOffset < 0) {
5,551✔
355
            startOffset = token.image.length + startOffset;
1,693✔
356
        }
357
        if (length === undefined) {
5,551!
NEW
358
            length = token.image.length - startOffset;
×
359
        }
360
        if (length < 0) {
5,551✔
361
            length = token.image.length + length;
1,693✔
362
        }
363
        return {
5,551✔
364
            text: token.image.substring(startOffset, startOffset + length),
365
            //xmltools startLine is 1-based, we need 0-based which is why we subtract 1 below
366
            location: util.createLocation(
367
                token.startLine - 1,
368
                token.startColumn - 1 + startOffset,
369
                token.endLine - 1,
370
                token.startColumn - 1 + (startOffset + length),
371
                this.options.srcPath
372
            )
373
        };
374
    }
375

376
    /**
377
     * Create a range based on the xmltools token
378
     */
379
    public rangeFromToken(token: IToken) {
380
        return util.createRange(
7,080✔
381
            token.startLine - 1,
382
            token.startColumn - 1,
383
            token.endLine - 1,
384
            token.endColumn
385
        );
386
    }
387

388
    private emptySGReferences(): SGReferences {
389
        return {
379✔
390
            scriptTagImports: []
391
        };
392
    }
393
}
394

395
//not exposed by @xml-tools/parser
396
interface IToken {
397
    image: string;
398
    startOffset: number;
399
    startLine?: number;
400
    startColumn?: number;
401
    endOffset?: number;
402
    endLine?: number;
403
    endColumn?: number;
404
}
405

406
export interface CstNodeLocation {
407
    startOffset: number;
408
    startLine: number;
409
    startColumn?: number;
410
    endOffset?: number;
411
    endLine?: number;
412
    endColumn?: number;
413
}
414

415
export interface SGParseOptions {
416
    /**
417
     * Path to the file where this source code originated
418
     */
419
    srcPath?: string;
420

421
    destPath: string;
422
    /**
423
     * Should locations be tracked. If false, the `range` property will be omitted
424
     * @default true
425
     */
426
    trackLocations?: boolean;
427
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc