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

rokucommunity / brighterscript / #13111

30 Sep 2024 04:35PM UTC coverage: 86.842% (-1.4%) from 88.193%
#13111

push

web-flow
Merge 25fc06528 into 3a2dc7282

11525 of 14034 branches covered (82.12%)

Branch coverage included in aggregate %.

6990 of 7581 new or added lines in 100 files covered. (92.2%)

83 existing lines in 18 files now uncovered.

12691 of 13851 relevant lines covered (91.63%)

29449.4 hits per line

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

96.21
/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 util from '../util';
1✔
5
import { SGAst, SGProlog, SGChildren, SGComponent, SGInterfaceField, SGInterfaceFunction, SGInterface, SGNode, SGScript, SGAttribute } from './SGTypes';
1✔
6
import type { SGElement, SGToken, SGReferences } from './SGTypes';
7
import { isSGComponent } from '../astUtils/xml';
1✔
8
import type { BsDiagnostic } from '../interfaces';
9
import type { Location, Range } from 'vscode-languageserver';
10

11
export default class SGParser {
1✔
12

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

18
    public tokens: IToken[];
19

20
    /**
21
     * The list of diagnostics found during the parse process
22
     */
23
    public diagnostics = [] as BsDiagnostic[];
454✔
24

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

30
    private _references: SGReferences;
31

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

44

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

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

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

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

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

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

91

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

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

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

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

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

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

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

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

173
        return {
391✔
174
            prolog: prolog,
175
            root: root
176
        };
177
    }
178

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

188
        const content = children.content?.[0];
725✔
189

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

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

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

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

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

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

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

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

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

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

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

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

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

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

395
    private getLocationFromRange(range: Range): Location {
396
        return util.createLocationFromRange(util.pathToUri(this.options.srcPath), range);
22✔
397
    }
398
}
399

400
//not exposed by @xml-tools/parser
401
interface IToken {
402
    image: string;
403
    startOffset: number;
404
    startLine?: number;
405
    startColumn?: number;
406
    endOffset?: number;
407
    endLine?: number;
408
    endColumn?: number;
409
}
410

411
export interface CstNodeLocation {
412
    startOffset: number;
413
    startLine: number;
414
    startColumn?: number;
415
    endOffset?: number;
416
    endLine?: number;
417
    endColumn?: number;
418
}
419

420
export interface SGParseOptions {
421
    /**
422
     * Path to the file where this source code originated
423
     */
424
    srcPath?: string;
425

426
    destPath: string;
427
    /**
428
     * Should locations be tracked. If false, the `range` property will be omitted
429
     * @default true
430
     */
431
    trackLocations?: boolean;
432
}
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

© 2025 Coveralls, Inc