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

rokucommunity / brighterscript / #13186

15 Oct 2024 11:38AM UTC coverage: 89.043% (+2.2%) from 86.831%
#13186

push

web-flow
Merge 2b9d8bd39 into 1519a87aa

7212 of 8538 branches covered (84.47%)

Branch coverage included in aggregate %.

10 of 10 new or added lines in 1 file covered. (100.0%)

539 existing lines in 53 files now uncovered.

9619 of 10364 relevant lines covered (92.81%)

1781.3 hits per line

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

90.32
/src/files/XmlFile.ts
1
import * as path from 'path';
1✔
2
import type { CodeWithSourceMap } from 'source-map';
3
import { SourceNode } from 'source-map';
1✔
4
import type { CompletionItem, Location, Position, Range } from 'vscode-languageserver';
5
import { DiagnosticCodeMap, diagnosticCodes } from '../DiagnosticMessages';
1✔
6
import type { FunctionScope } from '../FunctionScope';
7
import type { Callable, BsDiagnostic, File, FileReference, FunctionCall, CommentFlag } from '../interfaces';
8
import type { Program } from '../Program';
9
import util from '../util';
1✔
10
import SGParser, { rangeFromTokenValue } from '../parser/SGParser';
1✔
11
import chalk from 'chalk';
1✔
12
import { Cache } from '../Cache';
1✔
13
import type { DependencyGraph } from '../DependencyGraph';
14
import type { SGToken } from '../parser/SGTypes';
15
import { SGScript } from '../parser/SGTypes';
1✔
16
import { CommentFlagProcessor } from '../CommentFlagProcessor';
1✔
17
import type { IToken, TokenType } from 'chevrotain';
18
import { TranspileState } from '../parser/TranspileState';
1✔
19

20
export class XmlFile {
1✔
21
    constructor(
22
        public srcPath: string,
280✔
23
        /**
24
         * The absolute path to the file, relative to the pkg
25
         */
26
        public pkgPath: string,
280✔
27
        public program: Program
280✔
28
    ) {
29
        this.extension = path.extname(this.srcPath).toLowerCase();
280✔
30

31
        this.possibleCodebehindPkgPaths = [
280✔
32
            this.pkgPath.replace('.xml', '.bs'),
33
            this.pkgPath.replace('.xml', '.brs')
34
        ];
35
    }
36

37
    /**
38
     * The absolute path to the source location for this file
39
     * @deprecated use `srcPath` instead
40
     */
41
    public get pathAbsolute() {
UNCOV
42
        return this.srcPath;
×
43
    }
44
    public set pathAbsolute(value) {
UNCOV
45
        this.srcPath = value;
×
46
    }
47

48
    private cache = new Cache();
280✔
49

50
    /**
51
     * The list of possible autoImport codebehind pkg paths.
52
     */
53
    public possibleCodebehindPkgPaths: string[];
54

55
    /**
56
     * An unsubscribe function for the dependencyGraph subscription
57
     */
58
    private unsubscribeFromDependencyGraph: () => void;
59

60
    /**
61
     * Indicates whether this file needs to be validated.
62
     * Files are only ever validated a single time
63
     */
64
    public isValidated = false;
280✔
65

66
    /**
67
     * The extension for this file
68
     */
69
    public extension: string;
70

71
    public commentFlags = [] as CommentFlag[];
280✔
72

73
    /**
74
     * Will this file result in only comment or whitespace output? If so, it can be excluded from the output if that bsconfig setting is enabled.
75
     */
76
    readonly canBePruned = false;
280✔
77

78
    /**
79
     * The list of script imports delcared in the XML of this file.
80
     * This excludes parent imports and auto codebehind imports
81
     */
82
    public get scriptTagImports(): FileReference[] {
83
        return this.parser.references.scriptTagImports
482✔
84
            .map(tag => ({
405✔
85
                ...tag,
86
                sourceFile: this
87
            }));
88
    }
89

90
    /**
91
     * List of all pkgPaths to scripts that this XmlFile depends, regardless of whether they are loaded in the program or not.
92
     * This includes own dependencies and all parent compoent dependencies
93
     * coming from:
94
     *  - script tags
95
     *  - implied codebehind file
96
     *  - import statements from imported scripts or their descendents
97
     */
98
    public getAllDependencies() {
UNCOV
99
        return this.cache.getOrAdd(`allScriptImports`, () => {
×
UNCOV
100
            const value = this.dependencyGraph.getAllDependencies(this.dependencyGraphKey);
×
UNCOV
101
            return value;
×
102
        });
103
    }
104

105
    /**
106
     * List of all pkgPaths to scripts that this XmlFile depends on directly, regardless of whether they are loaded in the program or not.
107
     * This does not account for parent component scripts
108
     * coming from:
109
     *  - script tags
110
     *  - implied codebehind file
111
     *  - import statements from imported scripts or their descendents
112
     */
113
    public getOwnDependencies() {
114
        return this.cache.getOrAdd(`ownScriptImports`, () => {
204✔
115
            const value = this.dependencyGraph.getAllDependencies(this.dependencyGraphKey, [this.parentComponentDependencyGraphKey]);
184✔
116
            return value;
184✔
117
        });
118
    }
119

120
    /**
121
     * List of all pkgPaths to scripts that this XmlFile depends on that are actually loaded into the program.
122
     * This does not account for parent component scripts.
123
     * coming from:
124
     *  - script tags
125
     *  - inferred codebehind file
126
     *  - import statements from imported scripts or their descendants
127
     */
128
    public getAvailableScriptImports() {
129
        return this.cache.getOrAdd('allAvailableScriptImports', () => {
25✔
130

131
            let allDependencies = this.getOwnDependencies()
24✔
132
                //skip typedef files
133
                .filter(x => util.getExtension(x) !== '.d.bs');
39✔
134

135
            let result = [] as string[];
24✔
136
            let filesInProgram = this.program.getFiles(allDependencies);
24✔
137
            for (let file of filesInProgram) {
24✔
138
                result.push(file.pkgPath);
28✔
139
            }
140
            this.logDebug('computed allAvailableScriptImports', () => result);
24✔
141
            return result;
24✔
142
        });
143
    }
144

145
    public getDiagnostics() {
146
        return [...this.diagnostics];
157✔
147
    }
148

149
    public addDiagnostics(diagnostics: BsDiagnostic[]) {
150
        this.diagnostics.push(...diagnostics);
3✔
151
    }
152

153
    /**
154
     * The range of the entire file
155
     */
156
    public fileRange: Range;
157

158
    /**
159
     * A collection of diagnostics related to this file
160
     */
161
    public diagnostics = [] as BsDiagnostic[];
280✔
162

163
    public parser = new SGParser();
280✔
164

165
    //TODO implement the xml CDATA parsing, which would populate this list
166
    public callables = [] as Callable[];
280✔
167

168
    //TODO implement the xml CDATA parsing, which would populate this list
169
    public functionCalls = [] as FunctionCall[];
280✔
170

171
    public functionScopes = [] as FunctionScope[];
280✔
172

173
    /**
174
     * The name of the component that this component extends.
175
     * Available after `parse()`
176
     */
177
    public get parentComponentName(): SGToken {
178
        return this.parser?.references.extends;
1,265!
179
    }
180

181
    /**
182
     * The name of the component declared in this xml file
183
     * Available after `parse()`
184
     */
185
    public get componentName(): SGToken {
186
        return this.parser?.references.name;
2,525!
187
    }
188

189
    /**
190
     * Does this file need to be transpiled?
191
     */
192
    public needsTranspiled = false;
280✔
193

194
    /**
195
     * The AST for this file
196
     */
197
    public get ast() {
198
        return this.parser.ast;
311✔
199
    }
200

201
    /**
202
     * The full file contents
203
     */
204
    public fileContents: string;
205

206
    /**
207
     * Calculate the AST for this file
208
     * @param fileContents the xml source code to parse
209
     */
210
    public parse(fileContents: string) {
211
        this.fileContents = fileContents;
216✔
212

213
        this.parser.parse(this.pkgPath, fileContents);
216✔
214
        this.diagnostics = this.parser.diagnostics.map(diagnostic => ({
216✔
215
            ...diagnostic,
216
            file: this
217
        }));
218

219
        this.getCommentFlags(this.parser.tokens as any[]);
216✔
220

221
        //needsTranspiled should be true if an import is brighterscript
222
        this.needsTranspiled = this.needsTranspiled || this.ast.component?.scripts?.some(
216✔
223
            script => script.type?.indexOf('brighterscript') > 0 || script.uri?.endsWith('.bs')
161✔
224
        );
225
    }
226

227
    /**
228
     * @deprecated logic has moved into XmlFileValidator, this is now an empty function
229
     */
230
    public validate() {
231

232
    }
233

234
    /**
235
     * Collect all bs: comment flags
236
     */
237
    public getCommentFlags(tokens: Array<IToken & { tokenType: TokenType }>) {
238
        const processor = new CommentFlagProcessor(this, ['<!--'], diagnosticCodes, [DiagnosticCodeMap.unknownDiagnosticCode]);
216✔
239

240
        this.commentFlags = [];
216✔
241
        for (let token of tokens) {
216✔
242
            if (token.tokenType.name === 'Comment') {
6,502✔
243
                processor.tryAdd(
7✔
244
                    //remove the close comment symbol
245
                    token.image.replace(/\-\-\>$/, ''),
246
                    rangeFromTokenValue(token)
247
                );
248
            }
249
        }
250
        this.commentFlags.push(...processor.commentFlags);
216✔
251
        this.diagnostics.push(...processor.diagnostics);
216✔
252
    }
253

254
    private dependencyGraph: DependencyGraph;
255

256
    /**
257
     * Attach the file to the dependency graph so it can monitor changes.
258
     * Also notify the dependency graph of our current dependencies so other dependents can be notified.
259
     */
260
    public attachDependencyGraph(dependencyGraph: DependencyGraph) {
261
        this.dependencyGraph = dependencyGraph;
210✔
262
        if (this.unsubscribeFromDependencyGraph) {
210✔
263
            this.unsubscribeFromDependencyGraph();
2✔
264
        }
265

266
        //anytime a dependency changes, clean up some cached values
267
        this.unsubscribeFromDependencyGraph = dependencyGraph.onchange(this.dependencyGraphKey, () => {
210✔
268
            this.logDebug('clear cache because dependency graph changed');
320✔
269
            this.cache.clear();
320✔
270
        });
271

272
        let dependencies = [
210✔
273
            ...this.scriptTagImports.map(x => x.pkgPath.toLowerCase())
158✔
274
        ];
275
        //if autoImportComponentScript is enabled, add the .bs and .brs files with the same name
276
        if (this.program.options.autoImportComponentScript) {
210✔
277
            dependencies.push(
19✔
278
                //add the codebehind file dependencies.
279
                //These are kind of optional, so it doesn't hurt to just add both extension versions
280
                this.pkgPath.replace(/\.xml$/i, '.bs').toLowerCase(),
281
                this.pkgPath.replace(/\.xml$/i, '.brs').toLowerCase()
282
            );
283
        }
284
        const len = dependencies.length;
210✔
285
        for (let i = 0; i < len; i++) {
210✔
286
            const dep = dependencies[i];
196✔
287

288
            //add a dependency on `d.bs` file for every `.brs` file
289
            if (dep.slice(-4).toLowerCase() === '.brs') {
196✔
290
                dependencies.push(util.getTypedefPath(dep));
131✔
291
            }
292
        }
293

294
        if (this.parentComponentName) {
210✔
295
            dependencies.push(this.parentComponentDependencyGraphKey);
179✔
296
        }
297
        this.dependencyGraph.addOrReplace(this.dependencyGraphKey, dependencies);
210✔
298
    }
299

300
    /**
301
     * A slight hack. Gives the Program a way to support multiple components with the same name
302
     * without causing major issues. A value of 0 will be ignored as part of the dependency graph key.
303
     * Howver, a nonzero value will be used as part of the dependency graph key so this component doesn't
304
     * collide with the primary component. For example, if there are three components with the same name, you will
305
     * have the following dependency graph keys: ["component:CustomGrid", "component:CustomGrid[1]", "component:CustomGrid[2]"]
306
     */
307
    public dependencyGraphIndex = -1;
280✔
308

309
    /**
310
     * The key used in the dependency graph for this file.
311
     * If we have a component name, we will use that so we can be discoverable by child components.
312
     * If we don't have a component name, use the pkgPath so at least we can self-validate
313
     */
314
    public get dependencyGraphKey() {
315
        let key: string;
316
        if (this.componentName) {
1,003✔
317
            key = `component:${this.componentName.text}`.toLowerCase();
940✔
318
        } else {
319
            key = this.pkgPath.toLowerCase();
63✔
320
        }
321
        //if our index is not zero, then we are not the primary component with that name, and need to
322
        //append our index to the dependency graph key as to prevent collisions in the program.
323
        if (this.dependencyGraphIndex !== 0) {
1,003✔
324
            key += '[' + this.dependencyGraphIndex + ']';
21✔
325
        }
326
        return key;
1,003✔
327
    }
328

329
    /**
330
     * The key used in the dependency graph for this component's parent.
331
     * If we have aparent, we will use that. If we don't, this will return undefined
332
     */
333
    public get parentComponentDependencyGraphKey() {
334
        if (this.parentComponentName) {
363✔
335
            return `component:${this.parentComponentName.text}`.toLowerCase();
345✔
336
        } else {
337
            return undefined;
18✔
338
        }
339
    }
340

341
    /**
342
     * Determines if this xml file has a reference to the specified file (or if it's itself)
343
     */
344
    public doesReferenceFile(file: File) {
345
        return this.cache.getOrAdd(`doesReferenceFile: ${file.pkgPath}`, () => {
1✔
346
            if (file === this) {
1!
UNCOV
347
                return true;
×
348
            }
349
            let allDependencies = this.getOwnDependencies();
1✔
350
            for (let importPkgPath of allDependencies) {
1✔
351
                if (importPkgPath.toLowerCase() === file.pkgPath.toLowerCase()) {
2✔
352
                    return true;
1✔
353
                }
354
            }
355

356
            //if this is an xml file...do we extend the component it defines?
UNCOV
357
            if (path.extname(file.pkgPath).toLowerCase() === '.xml') {
×
358

359
                //didn't find any script imports for this file
UNCOV
360
                return false;
×
361
            }
UNCOV
362
            return false;
×
363
        });
364
    }
365

366
    /**
367
     * Get all available completions for the specified position
368
     */
369
    public getCompletions(position: Position): CompletionItem[] {
370
        let scriptImport = util.getScriptImportAtPosition(this.scriptTagImports, position);
5✔
371
        if (scriptImport) {
5✔
372
            return this.program.getScriptImportCompletions(this.pkgPath, scriptImport);
3✔
373
        } else {
374
            return [];
2✔
375
        }
376
    }
377

378
    /**
379
     * Get the parent component (the component this component extends)
380
     */
381
    public get parentComponent() {
382
        const result = this.cache.getOrAdd('parent', () => {
260✔
383
            return this.program.getComponent(this.parentComponentName?.text)?.file;
172✔
384
        });
385
        return result;
260✔
386
    }
387

388
    public getReferences(position: Position): Promise<Location[]> { //eslint-disable-line
389
        //TODO implement
UNCOV
390
        return null;
×
391
    }
392

393
    public getFunctionScopeAtPosition(position: Position, functionScopes?: FunctionScope[]): FunctionScope { //eslint-disable-line
394
        //TODO implement
UNCOV
395
        return null;
×
396
    }
397

398
    /**
399
     * Walk up the ancestor chain and aggregate all of the script tag imports
400
     */
401
    public getAncestorScriptTagImports(): FileReference[] {
402
        let result = [] as FileReference[];
34✔
403
        let parent = this.parentComponent;
34✔
404
        while (parent) {
34✔
405
            result.push(...parent.scriptTagImports);
34✔
406
            parent = parent.parentComponent;
34✔
407
        }
408
        return result;
34✔
409
    }
410

411
    /**
412
     * Remove this file from the dependency graph as a node
413
     */
414
    public detachDependencyGraph(dependencyGraph: DependencyGraph) {
UNCOV
415
        dependencyGraph.remove(this.dependencyGraphKey);
×
416

417
    }
418

419
    /**
420
     * Get the list of script imports that this file needs to include.
421
     * It compares the list of imports on this file to those of its parent,
422
     * and only includes the ones that are not found on the parent.
423
     * If no parent is found, all imports are returned
424
     */
425
    private getMissingImportsForTranspile() {
426
        let ownImports = this.getAvailableScriptImports();
23✔
427
        //add the bslib path to ownImports, it'll get filtered down below
428
        ownImports.push(this.program.bslibPkgPath);
23✔
429

430
        let parentImports = this.parentComponent?.getAvailableScriptImports() ?? [];
23!
431

432
        let parentMap = parentImports.reduce((map, pkgPath) => {
23✔
UNCOV
433
            map[pkgPath.toLowerCase()] = true;
×
434
            return map;
×
435
        }, {});
436

437
        //if the XML already has this import, skip this one
438
        let alreadyThereScriptImportMap = this.scriptTagImports.reduce((map, fileReference) => {
23✔
439
            map[fileReference.pkgPath.toLowerCase()] = true;
18✔
440
            return map;
18✔
441
        }, {});
442

443
        let resultMap = {};
23✔
444
        let result = [] as string[];
23✔
445
        for (let ownImport of ownImports) {
23✔
446
            const ownImportLower = ownImport.toLowerCase();
48✔
447
            if (
48✔
448
                //if the parent doesn't have this import
449
                !parentMap[ownImportLower] &&
126✔
450
                //the XML doesn't already have a script reference for this
451
                !alreadyThereScriptImportMap[ownImportLower] &&
452
                //the result doesn't already have this reference
453
                !resultMap[ownImportLower]
454
            ) {
455
                result.push(ownImport);
29✔
456
                resultMap[ownImportLower] = true;
29✔
457
            }
458
        }
459
        return result;
23✔
460
    }
461

462
    private logDebug(...args) {
463
        this.program.logger.debug('XmlFile', chalk.green(this.pkgPath), ...args);
344✔
464
    }
465

466
    private checkScriptsForPublishableImports(scripts: SGScript[]): [boolean, SGScript[]] {
467
        if (!this.program.options.pruneEmptyCodeFiles) {
25✔
468
            return [false, scripts];
22✔
469
        }
470
        const publishableScripts = scripts.filter(script => {
3✔
471
            const uriAttributeValue = script.attributes.find((v) => v.key.text === 'uri')?.value.text || '';
16!
472
            const pkgMapPath = util.getPkgPathFromTarget(this.pkgPath, uriAttributeValue);
8✔
473
            let file = this.program.getFile(pkgMapPath);
8✔
474
            if (!file && pkgMapPath.endsWith(this.program.bslibPkgPath)) {
8✔
475
                return true;
3✔
476
            }
477
            if (!file && pkgMapPath.endsWith('.brs')) {
5✔
478
                file = this.program.getFile(pkgMapPath.replace(/\.brs$/, '.bs'));
1✔
479
            }
480
            return !(file?.canBePruned);
5!
481
        });
482
        return [publishableScripts.length !== scripts.length, publishableScripts];
3✔
483
    }
484

485
    /**
486
     * Convert the brightscript/brighterscript source code into valid brightscript
487
     */
488
    public transpile(): CodeWithSourceMap {
489
        const state = new TranspileState(this.srcPath, this.program.options);
25✔
490

491
        const originalScripts = this.ast.component?.scripts ?? [];
25!
492
        const extraImportScripts = this.getMissingImportsForTranspile().map(uri => {
25✔
493
            const script = new SGScript();
29✔
494
            script.uri = util.getRokuPkgPath(uri.replace(/\.bs$/, '.brs'));
29✔
495
            return script;
29✔
496
        });
497

498
        const [scriptsHaveChanged, publishableScripts] = this.checkScriptsForPublishableImports([
25✔
499
            ...originalScripts,
500
            ...extraImportScripts
501
        ]);
502

503
        let transpileResult: SourceNode | undefined;
504
        if (this.needsTranspiled || extraImportScripts.length > 0 || scriptsHaveChanged) {
25✔
505
            //temporarily add the missing imports as script tags
506
            this.ast.component.scripts = publishableScripts;
22✔
507

508

509
            transpileResult = util.sourceNodeFromTranspileResult(null, null, state.srcPath, this.parser.ast.transpile(state));
22✔
510

511
            //restore the original scripts array
512
            this.ast.component.scripts = originalScripts;
22✔
513

514
        } else if (this.program.options.sourceMap) {
3✔
515
            //emit code as-is with a simple map to the original file location
516
            transpileResult = util.simpleMap(state.srcPath, this.fileContents);
1✔
517
        } else {
518
            //simple SourceNode wrapping the entire file to simplify the logic below
519
            transpileResult = new SourceNode(null, null, state.srcPath, this.fileContents);
2✔
520
        }
521

522
        //add the source map comment if configured to emit sourcemaps
523
        if (this.program.options.sourceMap) {
25✔
524
            return new SourceNode(null, null, state.srcPath, [
4✔
525
                transpileResult,
526
                //add the sourcemap reference comment
527
                `<!--//# sourceMappingURL=./${path.basename(state.srcPath)}.map -->`
528
            ]).toStringWithSourceMap();
529
        } else {
530
            return {
21✔
531
                code: transpileResult.toString(),
532
                map: undefined
533
            };
534
        }
535
    }
536

537
    public dispose() {
538
        //unsubscribe from any DependencyGraph subscriptions
539
        this.unsubscribeFromDependencyGraph?.();
206!
540
    }
541
}
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