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

rokucommunity / brighterscript / #15054

03 Jan 2026 09:12PM UTC coverage: 88.959% (-0.01%) from 88.969%
#15054

push

web-flow
Add artifact upload step to Datadog workflow

Added a step to upload build artifacts with configurable options.

7877 of 9338 branches covered (84.35%)

Branch coverage included in aggregate %.

10090 of 10859 relevant lines covered (92.92%)

1900.91 hits per line

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

87.55
/src/util.ts
1
import * as fs from 'fs';
1✔
2
import * as fsExtra from 'fs-extra';
1✔
3
import type { ParseError } from 'jsonc-parser';
4
import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser';
1✔
5
import * as path from 'path';
1✔
6
import { rokuDeploy, DefaultFiles } from 'roku-deploy';
1✔
7
import type { Diagnostic, Position, Range, Location, DiagnosticRelatedInformation } from 'vscode-languageserver';
8
import { URI } from 'vscode-uri';
1✔
9
import * as xml2js from 'xml2js';
1✔
10
import type { BsConfig, FinalizedBsConfig } from './BsConfig';
11
import { DiagnosticMessages } from './DiagnosticMessages';
1✔
12
import type { CallableContainer, BsDiagnostic, FileReference, CallableContainerMap, Plugin, ExpressionInfo, TranspileResult, MaybePromise, DisposableLike, PluginFactory } from './interfaces';
13
import { BooleanType } from './types/BooleanType';
1✔
14
import { DoubleType } from './types/DoubleType';
1✔
15
import { DynamicType } from './types/DynamicType';
1✔
16
import { FloatType } from './types/FloatType';
1✔
17
import { FunctionType } from './types/FunctionType';
1✔
18
import { IntegerType } from './types/IntegerType';
1✔
19
import { InvalidType } from './types/InvalidType';
1✔
20
import { LongIntegerType } from './types/LongIntegerType';
1✔
21
import { ObjectType } from './types/ObjectType';
1✔
22
import { StringType } from './types/StringType';
1✔
23
import { VoidType } from './types/VoidType';
1✔
24
import { ParseMode } from './parser/Parser';
1✔
25
import type { DottedGetExpression, VariableExpression } from './parser/Expression';
26
import { LogLevel, createLogger } from './logging';
1✔
27
import type { Identifier, Locatable, Token } from './lexer/Token';
28
import { TokenKind } from './lexer/TokenKind';
1✔
29
import { isAssignmentStatement, isBrsFile, isCallExpression, isCallfuncExpression, isDottedGetExpression, isExpression, isFunctionParameterExpression, isIndexedGetExpression, isNamespacedVariableNameExpression, isNewExpression, isVariableExpression, isXmlAttributeGetExpression, isXmlFile } from './astUtils/reflection';
1✔
30
import { WalkMode } from './astUtils/visitors';
1✔
31
import { CustomType } from './types/CustomType';
1✔
32
import { SourceNode } from 'source-map';
1✔
33
import type { SGAttribute } from './parser/SGTypes';
34
import * as requireRelative from 'require-relative';
1✔
35
import type { BrsFile } from './files/BrsFile';
36
import type { XmlFile } from './files/XmlFile';
37
import type { AstNode, Expression, Statement } from './parser/AstNode';
38
import { components, events, interfaces } from './roku-types';
1✔
39

40
export class Util {
1✔
41
    public clearConsole() {
42
        // process.stdout.write('\x1Bc');
43
    }
44

45
    /**
46
     * Get the version of brighterscript
47
     */
48
    public getBrighterScriptVersion() {
49
        try {
7✔
50
            return fsExtra.readJsonSync(`${__dirname}/../package.json`).version;
7✔
51
        } catch {
52
            return undefined;
×
53
        }
54
    }
55

56
    /**
57
     * Returns the number of parent directories in the filPath
58
     */
59
    public getParentDirectoryCount(filePath: string | undefined) {
60
        if (!filePath) {
912!
61
            return -1;
×
62
        } else {
63
            return filePath.replace(/^pkg:/, '').split(/[\\\/]/).length - 1;
912✔
64
        }
65
    }
66

67
    /**
68
     * Determine if the file exists
69
     */
70
    public async pathExists(filePath: string | undefined) {
71
        if (!filePath) {
240✔
72
            return false;
1✔
73
        } else {
74
            return fsExtra.pathExists(filePath);
239✔
75
        }
76
    }
77

78
    /**
79
     * Determine if the file exists
80
     */
81
    public pathExistsSync(filePath: string | undefined) {
82
        if (!filePath) {
8,807!
83
            return false;
×
84
        } else {
85
            return fsExtra.pathExistsSync(filePath);
8,807✔
86
        }
87
    }
88

89
    /**
90
     * Determine if this path is a directory
91
     */
92
    public isDirectorySync(dirPath: string | undefined) {
93
        try {
80✔
94
            return dirPath !== undefined && fs.existsSync(dirPath) && fs.lstatSync(dirPath).isDirectory();
80✔
95
        } catch (e) {
96
            return false;
×
97
        }
98
    }
99

100
    /**
101
     * Read a file from disk. If a failure occurrs, simply return undefined
102
     * @param filePath path to the file
103
     * @returns the string contents, or undefined if the file doesn't exist
104
     */
105
    public readFileSync(filePath: string): Buffer | undefined {
106
        try {
11✔
107
            return fsExtra.readFileSync(filePath);
11✔
108
        } catch (e) {
109
            return undefined;
2✔
110
        }
111
    }
112

113
    /**
114
     * Given a pkg path of any kind, transform it to a roku-specific pkg path (i.e. "pkg:/some/path.brs")
115
     */
116
    public sanitizePkgPath(pkgPath: string) {
117
        pkgPath = pkgPath.replace(/\\/g, '/');
×
118
        //if there's no protocol, assume it's supposed to start with `pkg:/`
119
        if (!this.startsWithProtocol(pkgPath)) {
×
120
            pkgPath = 'pkg:/' + pkgPath;
×
121
        }
122
        return pkgPath;
×
123
    }
124

125
    /**
126
     * Determine if the given path starts with a protocol
127
     */
128
    public startsWithProtocol(path: string) {
129
        return !!/^[-a-z]+:\//i.exec(path);
×
130
    }
131

132
    /**
133
     * Given a pkg path of any kind, transform it to a roku-specific pkg path (i.e. "pkg:/some/path.brs")
134
     */
135
    public getRokuPkgPath(pkgPath: string) {
136
        pkgPath = pkgPath.replace(/\\/g, '/');
39✔
137
        return 'pkg:/' + pkgPath;
39✔
138
    }
139

140
    /**
141
     * Given a path to a file/directory, replace all path separators with the current system's version.
142
     */
143
    public pathSepNormalize(filePath: string, separator?: string) {
144
        if (!filePath) {
248!
145
            return filePath;
×
146
        }
147
        separator = separator ? separator : path.sep;
248✔
148
        return filePath.replace(/[\\/]+/g, separator);
248✔
149
    }
150

151
    /**
152
     * Find the path to the config file.
153
     * If the config file path doesn't exist
154
     * @param cwd the current working directory where the search for configs should begin
155
     */
156
    public getConfigFilePath(cwd?: string) {
157
        cwd = cwd ?? process.cwd();
94✔
158
        let configPath = path.join(cwd, 'bsconfig.json');
94✔
159
        //find the nearest config file path
160
        for (let i = 0; i < 100; i++) {
94✔
161
            if (this.pathExistsSync(configPath)) {
8,807✔
162
                return configPath;
6✔
163
            } else {
164
                let parentDirPath = path.dirname(path.dirname(configPath));
8,801✔
165
                configPath = path.join(parentDirPath, 'bsconfig.json');
8,801✔
166
            }
167
        }
168
    }
169

170
    public getRangeFromOffsetLength(text: string, offset: number, length: number) {
171
        let lineIndex = 0;
2✔
172
        let colIndex = 0;
2✔
173
        for (let i = 0; i < text.length; i++) {
2✔
174
            if (offset === i) {
158!
175
                break;
×
176
            }
177
            let char = text[i];
158✔
178
            if (char === '\n' || (char === '\r' && text[i + 1] === '\n')) {
158!
179
                lineIndex++;
4✔
180
                colIndex = 0;
4✔
181
                i++;
4✔
182
                continue;
4✔
183
            } else {
184
                colIndex++;
154✔
185
            }
186
        }
187
        return util.createRange(lineIndex, colIndex, lineIndex, colIndex + length);
2✔
188
    }
189

190
    /**
191
     * Load the contents of a config file.
192
     * If the file extends another config, this will load the base config as well.
193
     * @param configFilePath the relative or absolute path to a brighterscript config json file
194
     * @param parentProjectPaths a list of parent config files. This is used by this method to recursively build the config list
195
     */
196
    public loadConfigFile(configFilePath: string | undefined, parentProjectPaths?: string[], cwd = process.cwd()): BsConfig | undefined {
12✔
197
        if (configFilePath) {
75✔
198
            //if the config file path starts with question mark, then it's optional. return undefined if it doesn't exist
199
            if (configFilePath.startsWith('?')) {
74✔
200
                //remove leading question mark
201
                configFilePath = configFilePath.substring(1);
1✔
202
                if (fsExtra.pathExistsSync(path.resolve(cwd, configFilePath)) === false) {
1!
203
                    return undefined;
1✔
204
                }
205
            }
206
            //keep track of the inheritance chain
207
            parentProjectPaths = parentProjectPaths ? parentProjectPaths : [];
73✔
208
            configFilePath = path.resolve(cwd, configFilePath);
73✔
209
            if (parentProjectPaths?.includes(configFilePath)) {
73!
210
                parentProjectPaths.push(configFilePath);
1✔
211
                parentProjectPaths.reverse();
1✔
212
                throw new Error('Circular dependency detected: "' + parentProjectPaths.join('" => ') + '"');
1✔
213
            }
214
            //load the project file
215
            let projectFileContents = fsExtra.readFileSync(configFilePath).toString();
72✔
216
            let parseErrors = [] as ParseError[];
72✔
217
            let projectConfig = parseJsonc(projectFileContents, parseErrors, {
72✔
218
                allowEmptyContent: true,
219
                allowTrailingComma: true,
220
                disallowComments: false
221
            }) as BsConfig ?? {};
72✔
222
            if (parseErrors.length > 0) {
72✔
223
                let err = parseErrors[0];
2✔
224
                let diagnostic = {
2✔
225
                    ...DiagnosticMessages.bsConfigJsonHasSyntaxErrors(printParseErrorCode(parseErrors[0].error)),
226
                    file: {
227
                        srcPath: configFilePath
228
                    },
229
                    range: this.getRangeFromOffsetLength(projectFileContents, err.offset, err.length)
230
                } as BsDiagnostic;
231
                throw diagnostic; //eslint-disable-line @typescript-eslint/no-throw-literal
2✔
232
            }
233

234
            let projectFileCwd = path.dirname(configFilePath);
70✔
235

236
            //`plugins` paths should be relative to the current bsconfig
237
            this.resolvePathsRelativeTo(projectConfig, 'plugins', projectFileCwd);
70✔
238

239
            //`require` paths should be relative to cwd
240
            util.resolvePathsRelativeTo(projectConfig, 'require', projectFileCwd);
70✔
241

242
            let result: BsConfig;
243
            //if the project has a base file, load it
244
            if (projectConfig && typeof projectConfig.extends === 'string') {
70✔
245
                let baseProjectConfig = this.loadConfigFile(projectConfig.extends, [...parentProjectPaths, configFilePath], projectFileCwd);
9✔
246
                //extend the base config with the current project settings
247
                result = { ...baseProjectConfig, ...projectConfig };
7✔
248
            } else {
249
                result = projectConfig;
61✔
250
                let ancestors = parentProjectPaths ? parentProjectPaths : [];
61!
251
                ancestors.push(configFilePath);
61✔
252
                (result as any)._ancestors = parentProjectPaths;
61✔
253
            }
254

255
            //make any paths in the config absolute (relative to the CURRENT config file)
256
            if (result.outFile) {
68✔
257
                result.outFile = path.resolve(projectFileCwd, result.outFile);
4✔
258
            }
259
            if (result.rootDir) {
68✔
260
                result.rootDir = path.resolve(projectFileCwd, result.rootDir);
11✔
261
            }
262
            if (result.cwd) {
68✔
263
                result.cwd = path.resolve(projectFileCwd, result.cwd);
1✔
264
            }
265
            if (result.stagingDir) {
68✔
266
                result.stagingDir = path.resolve(projectFileCwd, result.stagingDir);
2✔
267
            }
268
            if (result.sourceRoot && result.resolveSourceRoot) {
68✔
269
                result.sourceRoot = path.resolve(projectFileCwd, result.sourceRoot);
2✔
270
            }
271
            return result;
68✔
272
        }
273
    }
274

275
    /**
276
     * Convert relative paths to absolute paths, relative to the given directory. Also de-dupes the paths. Modifies the array in-place
277
     * @param collection usually a bsconfig.
278
     * @param key a key of the config to read paths from (usually this is `'plugins'` or `'require'`)
279
     * @param relativeDir the path to the folder where the paths should be resolved relative to. This should be an absolute path
280
     */
281
    public resolvePathsRelativeTo(collection: any, key: string, relativeDir: string) {
282
        if (!collection[key]) {
142✔
283
            return;
138✔
284
        }
285
        const result = new Set<string>();
4✔
286
        for (const p of collection[key] as string[] ?? []) {
4!
287
            if (p) {
12✔
288
                result.add(
11✔
289
                    p?.startsWith('.') ? path.resolve(relativeDir, p) : p
44!
290
                );
291
            }
292
        }
293
        collection[key] = [...result];
4✔
294
    }
295

296
    /**
297
     * Do work within the scope of a changed current working directory
298
     * @param targetCwd the cwd where the work should be performed
299
     * @param callback a function to call when the cwd has been changed to `targetCwd`
300
     */
301
    public cwdWork<T>(targetCwd: string | null | undefined, callback: () => T): T {
302
        let originalCwd = process.cwd();
4✔
303
        if (targetCwd) {
4!
304
            process.chdir(targetCwd);
4✔
305
        }
306

307
        let result: T;
308
        let err;
309

310
        try {
4✔
311
            result = callback();
4✔
312
        } catch (e) {
313
            err = e;
×
314
        }
315

316
        if (targetCwd) {
4!
317
            process.chdir(originalCwd);
4✔
318
        }
319

320
        if (err) {
4!
321
            throw err;
×
322
        } else {
323
            //justification: `result` is set as long as `err` is not set and vice versa
324
            return result!;
4✔
325
        }
326
    }
327

328
    /**
329
     * Given a BsConfig object, start with defaults,
330
     * merge with bsconfig.json and the provided options.
331
     * @param config a bsconfig object to use as the baseline for the resulting config
332
     */
333
    public normalizeAndResolveConfig(config: BsConfig | undefined): FinalizedBsConfig {
334
        let result = this.normalizeConfig({});
146✔
335

336
        if (config?.noProject) {
146✔
337
            return result;
1✔
338
        }
339

340
        //if no options were provided, try to find a bsconfig.json file
341
        if (!config || !config.project) {
145✔
342
            result.project = this.getConfigFilePath(config?.cwd);
89✔
343
        } else {
344
            //use the config's project link
345
            result.project = config.project;
56✔
346
        }
347
        if (result.project) {
145✔
348
            let configFile = this.loadConfigFile(result.project, undefined, config?.cwd);
59!
349
            result = Object.assign(result, configFile);
56✔
350
        }
351
        //override the defaults with the specified options
352
        result = Object.assign(result, config);
142✔
353
        return result;
142✔
354
    }
355

356
    /**
357
     * Set defaults for any missing items
358
     * @param config a bsconfig object to use as the baseline for the resulting config
359
     */
360
    public normalizeConfig(config: BsConfig | undefined): FinalizedBsConfig {
361
        config = config ?? {} as BsConfig;
1,724✔
362

363
        const cwd = config.cwd ?? process.cwd();
1,724✔
364
        const rootFolderName = path.basename(cwd);
1,724✔
365
        const retainStagingDir = (config.retainStagingDir ?? config.retainStagingFolder) === true ? true : false;
1,724✔
366

367
        let logLevel: LogLevel = LogLevel.log;
1,724✔
368

369
        if (typeof config.logLevel === 'string') {
1,724✔
370
            logLevel = LogLevel[(config.logLevel as string).toLowerCase()] ?? LogLevel.log;
2!
371
        }
372

373
        let bslibDestinationDir = config.bslibDestinationDir ?? 'source';
1,724✔
374
        if (bslibDestinationDir !== 'source') {
1,724✔
375
            // strip leading and trailing slashes
376
            bslibDestinationDir = bslibDestinationDir.replace(/^(\/*)(.*?)(\/*)$/, '$2');
4✔
377
        }
378

379
        const configWithDefaults: Omit<FinalizedBsConfig, 'rootDir'> = {
1,724✔
380
            cwd: cwd,
381
            deploy: config.deploy === true ? true : false,
1,724!
382
            //use default files array from rokuDeploy
383
            files: config.files ?? [...DefaultFiles],
5,172✔
384
            createPackage: config.createPackage === false ? false : true,
1,724✔
385
            outFile: config.outFile ?? `./out/${rootFolderName}.zip`,
5,172✔
386
            sourceMap: config.sourceMap === true,
387
            username: config.username ?? 'rokudev',
5,172✔
388
            watch: config.watch === true ? true : false,
1,724!
389
            emitFullPaths: config.emitFullPaths === true ? true : false,
1,724!
390
            retainStagingDir: retainStagingDir,
391
            retainStagingFolder: retainStagingDir,
392
            copyToStaging: config.copyToStaging === false ? false : true,
1,724✔
393
            ignoreErrorCodes: config.ignoreErrorCodes ?? [],
5,172✔
394
            diagnosticSeverityOverrides: config.diagnosticSeverityOverrides ?? {},
5,172✔
395
            diagnosticFilters: config.diagnosticFilters ?? [],
5,172✔
396
            plugins: config.plugins ?? [],
5,172✔
397
            pruneEmptyCodeFiles: config.pruneEmptyCodeFiles === true ? true : false,
1,724✔
398
            autoImportComponentScript: config.autoImportComponentScript === true ? true : false,
1,724✔
399
            showDiagnosticsInConsole: config.showDiagnosticsInConsole === false ? false : true,
1,724✔
400
            sourceRoot: config.sourceRoot ? standardizePath(config.sourceRoot) : undefined,
1,724✔
401
            resolveSourceRoot: config.resolveSourceRoot === true ? true : false,
1,724!
402
            allowBrighterScriptInBrightScript: config.allowBrighterScriptInBrightScript === true ? true : false,
1,724!
403
            emitDefinitions: config.emitDefinitions === true ? true : false,
1,724!
404
            removeParameterTypes: config.removeParameterTypes === true ? true : false,
1,724!
405
            logLevel: logLevel,
406
            bslibDestinationDir: bslibDestinationDir
407
        };
408

409
        //mutate `config` in case anyone is holding a reference to the incomplete one
410
        const merged: FinalizedBsConfig = Object.assign(config, configWithDefaults);
1,724✔
411

412
        return merged;
1,724✔
413
    }
414

415
    /**
416
     * Get the root directory from options.
417
     * Falls back to options.cwd.
418
     * Falls back to process.cwd
419
     * @param options a bsconfig object
420
     */
421
    public getRootDir(options: BsConfig) {
422
        if (!options) {
1,536!
423
            throw new Error('Options is required');
×
424
        }
425
        let cwd = options.cwd;
1,536✔
426
        cwd = cwd ? cwd : process.cwd();
1,536!
427
        let rootDir = options.rootDir ? options.rootDir : cwd;
1,536✔
428

429
        rootDir = path.resolve(cwd, rootDir);
1,536✔
430

431
        return rootDir;
1,536✔
432
    }
433

434
    /**
435
     * Given a list of callables as a dictionary indexed by their full name (namespace included, transpiled to underscore-separated.
436
     */
437
    public getCallableContainersByLowerName(callables: CallableContainer[]): CallableContainerMap {
438
        //find duplicate functions
439
        const result = new Map<string, CallableContainer[]>();
2,309✔
440

441
        for (let callableContainer of callables) {
2,309✔
442
            let lowerName = callableContainer.callable.getName(ParseMode.BrightScript).toLowerCase();
178,607✔
443

444
            //create a new array for this name
445
            const list = result.get(lowerName);
178,607✔
446
            if (list) {
178,607✔
447
                list.push(callableContainer);
9,266✔
448
            } else {
449
                result.set(lowerName, [callableContainer]);
169,341✔
450
            }
451
        }
452
        return result;
2,309✔
453
    }
454

455
    /**
456
     * Split a file by newline characters (LF or CRLF)
457
     */
458
    public getLines(text: string) {
459
        return text.split(/\r?\n/);
×
460
    }
461

462
    /**
463
     * Given an absolute path to a source file, and a target path,
464
     * compute the pkg path for the target relative to the source file's location
465
     */
466
    public getPkgPathFromTarget(containingFilePathAbsolute: string, targetPath: string) {
467
        // https://regex101.com/r/w7CG2N/1
468
        const regexp = /^(?:pkg|libpkg):(\/)?/i;
232✔
469
        const [fullScheme, slash] = regexp.exec(targetPath) ?? [];
232✔
470
        //if the target starts with 'pkg:' or 'libpkg:' then it's an absolute path. Return as is
471
        if (slash) {
232✔
472
            targetPath = targetPath.substring(fullScheme.length);
93✔
473
            if (targetPath === '') {
93✔
474
                return null;
2✔
475
            } else {
476
                return path.normalize(targetPath);
91✔
477
            }
478
        }
479
        //if the path is exactly `pkg:` or `libpkg:`
480
        if (targetPath === fullScheme && !slash) {
139✔
481
            return null;
2✔
482
        }
483

484
        //remove the filename
485
        let containingFolder = path.normalize(path.dirname(containingFilePathAbsolute));
137✔
486
        //start with the containing folder, split by slash
487
        let result = containingFolder.split(path.sep);
137✔
488

489
        //split on slash
490
        let targetParts = path.normalize(targetPath).split(path.sep);
137✔
491

492
        for (let part of targetParts) {
137✔
493
            if (part === '' || part === '.') {
145✔
494
                //do nothing, it means current directory
495
                continue;
4✔
496
            }
497
            if (part === '..') {
141✔
498
                //go up one directory
499
                result.pop();
6✔
500
            } else {
501
                result.push(part);
135✔
502
            }
503
        }
504
        return result.join(path.sep);
137✔
505
    }
506

507
    /**
508
     * Compute the relative path from the source file to the target file
509
     * @param pkgSrcPath  - the absolute path to the source, where cwd is the package location
510
     * @param pkgTargetPath  - the absolute path to the target, where cwd is the package location
511
     */
512
    public getRelativePath(pkgSrcPath: string, pkgTargetPath: string) {
513
        pkgSrcPath = path.normalize(pkgSrcPath);
8✔
514
        pkgTargetPath = path.normalize(pkgTargetPath);
8✔
515

516
        //break by path separator
517
        let sourceParts = pkgSrcPath.split(path.sep);
8✔
518
        let targetParts = pkgTargetPath.split(path.sep);
8✔
519

520
        let commonParts = [] as string[];
8✔
521
        //find their common root
522
        for (let i = 0; i < targetParts.length; i++) {
8✔
523
            if (targetParts[i].toLowerCase() === sourceParts[i].toLowerCase()) {
14✔
524
                commonParts.push(targetParts[i]);
6✔
525
            } else {
526
                //we found a non-matching part...so no more commonalities past this point
527
                break;
8✔
528
            }
529
        }
530

531
        //throw out the common parts from both sets
532
        sourceParts.splice(0, commonParts.length);
8✔
533
        targetParts.splice(0, commonParts.length);
8✔
534

535
        //throw out the filename part of source
536
        sourceParts.splice(sourceParts.length - 1, 1);
8✔
537
        //start out by adding updir paths for each remaining source part
538
        let resultParts = sourceParts.map(() => '..');
8✔
539

540
        //now add every target part
541
        resultParts = [...resultParts, ...targetParts];
8✔
542
        return path.join(...resultParts);
8✔
543
    }
544

545
    /**
546
     * Walks left in a DottedGetExpression and returns a VariableExpression if found, or undefined if not found
547
     */
548
    public findBeginningVariableExpression(dottedGet: DottedGetExpression): VariableExpression | undefined {
549
        let left: any = dottedGet;
48✔
550
        while (left) {
48✔
551
            if (isVariableExpression(left)) {
75✔
552
                return left;
48✔
553
            } else if (isDottedGetExpression(left)) {
27!
554
                left = left.obj;
27✔
555
            } else {
556
                break;
×
557
            }
558
        }
559
    }
560

561
    /**
562
     * Do `a` and `b` overlap by at least one character. This returns false if they are at the edges. Here's some examples:
563
     * ```
564
     * | true | true | true | true | true | false | false | false | false |
565
     * |------|------|------|------|------|-------|-------|-------|-------|
566
     * | aa   |  aaa |  aaa | aaa  |  a   |  aa   |    aa | a     |     a |
567
     * |  bbb | bb   |  bbb |  b   | bbb  |    bb |  bb   |     b | a     |
568
     * ```
569
     */
570
    public rangesIntersect(a: Range | undefined, b: Range | undefined) {
571
        //stop if the either range is misisng
572
        if (!a || !b) {
11✔
573
            return false;
2✔
574
        }
575

576
        // Check if `a` is before `b`
577
        if (a.end.line < b.start.line || (a.end.line === b.start.line && a.end.character <= b.start.character)) {
9✔
578
            return false;
1✔
579
        }
580

581
        // Check if `b` is before `a`
582
        if (b.end.line < a.start.line || (b.end.line === a.start.line && b.end.character <= a.start.character)) {
8✔
583
            return false;
1✔
584
        }
585

586
        // These ranges must intersect
587
        return true;
7✔
588
    }
589

590
    /**
591
     * Do `a` and `b` overlap by at least one character or touch at the edges
592
     * ```
593
     * | true | true | true | true | true | true  | true  | false | false |
594
     * |------|------|------|------|------|-------|-------|-------|-------|
595
     * | aa   |  aaa |  aaa | aaa  |  a   |  aa   |    aa | a     |     a |
596
     * |  bbb | bb   |  bbb |  b   | bbb  |    bb |  bb   |     b | a     |
597
     * ```
598
     */
599
    public rangesIntersectOrTouch(a: Range | undefined, b: Range | undefined) {
600
        //stop if the either range is misisng
601
        if (!a || !b) {
29✔
602
            return false;
2✔
603
        }
604
        // Check if `a` is before `b`
605
        if (a.end.line < b.start.line || (a.end.line === b.start.line && a.end.character < b.start.character)) {
27✔
606
            return false;
2✔
607
        }
608

609
        // Check if `b` is before `a`
610
        if (b.end.line < a.start.line || (b.end.line === a.start.line && b.end.character < a.start.character)) {
25✔
611
            return false;
2✔
612
        }
613

614
        // These ranges must intersect
615
        return true;
23✔
616
    }
617

618
    /**
619
     * Test if `position` is in `range`. If the position is at the edges, will return true.
620
     * Adapted from core vscode
621
     */
622
    public rangeContains(range: Range | undefined, position: Position | undefined) {
623
        return this.comparePositionToRange(position, range) === 0;
10,385✔
624
    }
625

626
    public comparePositionToRange(position: Position | undefined, range: Range | undefined) {
627
        //stop if the either range is misisng
628
        if (!position || !range) {
10,735✔
629
            return 0;
2✔
630
        }
631

632
        if (position.line < range.start.line || (position.line === range.start.line && position.character < range.start.character)) {
10,733✔
633
            return -1;
702✔
634
        }
635
        if (position.line > range.end.line || (position.line === range.end.line && position.character > range.end.character)) {
10,031✔
636
            return 1;
7,341✔
637
        }
638
        return 0;
2,690✔
639
    }
640

641
    /**
642
     * Parse an xml file and get back a javascript object containing its results
643
     */
644
    public parseXml(text: string) {
645
        return new Promise<any>((resolve, reject) => {
×
646
            xml2js.parseString(text, (err, data) => {
×
647
                if (err) {
×
648
                    reject(err);
×
649
                } else {
650
                    resolve(data);
×
651
                }
652
            });
653
        });
654
    }
655

656
    public propertyCount(object: Record<string, unknown>) {
657
        let count = 0;
×
658
        for (let key in object) {
×
659
            if (object.hasOwnProperty(key)) {
×
660
                count++;
×
661
            }
662
        }
663
        return count;
×
664
    }
665

666
    public padLeft(subject: string, totalLength: number, char: string) {
667
        totalLength = totalLength > 1000 ? 1000 : totalLength;
1!
668
        while (subject.length < totalLength) {
1✔
669
            subject = char + subject;
1,000✔
670
        }
671
        return subject;
1✔
672
    }
673

674
    /**
675
     * Force the drive letter to lower case
676
     */
677
    public driveLetterToLower(fullPath: string) {
678
        if (fullPath) {
2!
679
            let firstCharCode = fullPath.charCodeAt(0);
2✔
680
            if (
2!
681
                //is upper case A-Z
682
                firstCharCode >= 65 && firstCharCode <= 90 &&
6✔
683
                //next char is colon
684
                fullPath[1] === ':'
685
            ) {
686
                fullPath = fullPath[0].toLowerCase() + fullPath.substring(1);
2✔
687
            }
688
        }
689
        return fullPath;
2✔
690
    }
691

692
    /**
693
     * Replace the first instance of `search` in `subject` with `replacement`
694
     */
695
    public replaceCaseInsensitive(subject: string, search: string, replacement: string) {
696
        let idx = subject.toLowerCase().indexOf(search.toLowerCase());
1,006✔
697
        if (idx > -1) {
1,006!
698
            let result = subject.substring(0, idx) + replacement + subject.substring(idx + search.length);
1,006✔
699
            return result;
1,006✔
700
        } else {
701
            return subject;
×
702
        }
703
    }
704

705
    /**
706
     * Determine if two arrays containing primitive values are equal.
707
     * This considers order and compares by equality.
708
     */
709
    public areArraysEqual(arr1: any[], arr2: any[]) {
710
        if (arr1.length !== arr2.length) {
8✔
711
            return false;
3✔
712
        }
713
        for (let i = 0; i < arr1.length; i++) {
5✔
714
            if (arr1[i] !== arr2[i]) {
7✔
715
                return false;
3✔
716
            }
717
        }
718
        return true;
2✔
719
    }
720
    /**
721
     * Does the string appear to be a uri (i.e. does it start with `file:`)
722
     */
723
    public isUriLike(filePath: string) {
724
        return filePath?.indexOf('file:') === 0;// eslint-disable-line @typescript-eslint/prefer-string-starts-ends-with
806!
725
    }
726

727
    /**
728
     * Given a file path, convert it to a URI string
729
     */
730
    public pathToUri(filePath: string) {
731
        if (!filePath) {
609!
732
            return filePath;
×
733
        } else if (this.isUriLike(filePath)) {
609✔
734
            return filePath;
120✔
735
        } else {
736
            return URI.file(filePath).toString();
489✔
737
        }
738
    }
739

740
    /**
741
     * Given a URI, convert that to a regular fs path
742
     */
743
    public uriToPath(uri: string) {
744
        //if this doesn't look like a URI, then assume it's already a path
745
        if (this.isUriLike(uri) === false) {
197✔
746
            return uri;
21✔
747
        }
748
        let parsedPath = URI.parse(uri).fsPath;
176✔
749

750
        //Uri annoyingly converts all drive letters to lower case...so this will bring back whatever case it came in as
751
        let match = /\/\/\/([a-z]:)/i.exec(uri);
176✔
752
        if (match) {
176✔
753
            let originalDriveCasing = match[1];
146✔
754
            parsedPath = originalDriveCasing + parsedPath.substring(2);
146✔
755
        }
756
        const normalizedPath = path.normalize(parsedPath);
176✔
757
        return normalizedPath;
176✔
758
    }
759

760

761
    /**
762
     * Get the outDir from options, taking into account cwd and absolute outFile paths
763
     */
764
    public getOutDir(options: FinalizedBsConfig) {
765
        options = this.normalizeConfig(options);
6✔
766
        let cwd = path.normalize(options.cwd ? options.cwd : process.cwd());
6!
767
        if (path.isAbsolute(options.outFile)) {
6!
768
            return path.dirname(options.outFile);
×
769
        } else {
770
            return path.normalize(path.join(cwd, path.dirname(options.outFile)));
6✔
771
        }
772
    }
773

774
    /**
775
     * Get paths to all files on disc that match this project's source list
776
     */
777
    public async getFilePaths(options: FinalizedBsConfig) {
778
        let rootDir = this.getRootDir(options);
119✔
779

780
        let files = await rokuDeploy.getFilePaths(options.files, rootDir);
119✔
781
        return files;
119✔
782
    }
783

784
    /**
785
     * Given a path to a brs file, compute the path to a theoretical d.bs file.
786
     * Only `.brs` files can have typedef path, so return undefined for everything else
787
     */
788
    public getTypedefPath(brsSrcPath: string) {
789
        const typedefPath = brsSrcPath
2,156✔
790
            .replace(/\.brs$/i, '.d.bs')
791
            .toLowerCase();
792

793
        if (typedefPath.endsWith('.d.bs')) {
2,156✔
794
            return typedefPath;
1,360✔
795
        } else {
796
            return undefined;
796✔
797
        }
798
    }
799

800
    /**
801
     * Determine whether this diagnostic should be supressed or not, based on brs comment-flags
802
     */
803
    public diagnosticIsSuppressed(diagnostic: BsDiagnostic) {
804
        const diagnosticCode = typeof diagnostic.code === 'string' ? diagnostic.code.toLowerCase() : diagnostic.code;
399✔
805
        for (let flag of diagnostic.file?.commentFlags ?? []) {
399✔
806
            //this diagnostic is affected by this flag
807
            if (diagnostic.range && this.rangeContains(flag.affectedRange, diagnostic.range.start)) {
40✔
808
                //if the flag acts upon this diagnostic's code
809
                if (flag.codes === null || (diagnosticCode !== undefined && flag.codes.includes(diagnosticCode))) {
31✔
810
                    return true;
25✔
811
                }
812
            }
813
        }
814
    }
815

816
    /**
817
     * Walks up the chain to find the closest bsconfig.json file
818
     */
819
    public async findClosestConfigFile(currentPath: string): Promise<string | undefined> {
820
        //make the path absolute
821
        currentPath = path.resolve(
4✔
822
            path.normalize(
823
                currentPath
824
            )
825
        );
826

827
        let previousPath: string | undefined;
828
        //using ../ on the root of the drive results in the same file path, so that's how we know we reached the top
829
        while (previousPath !== currentPath) {
4✔
830
            previousPath = currentPath;
10✔
831

832
            let bsPath = path.join(currentPath, 'bsconfig.json');
10✔
833
            let brsPath = path.join(currentPath, 'brsconfig.json');
10✔
834
            if (await this.pathExists(bsPath)) {
10✔
835
                return bsPath;
2✔
836
            } else if (await this.pathExists(brsPath)) {
8✔
837
                return brsPath;
2✔
838
            } else {
839
                //walk upwards one directory
840
                currentPath = path.resolve(path.join(currentPath, '../'));
6✔
841
            }
842
        }
843
        //got to the root path, no config file exists
844
    }
845

846
    /**
847
     * Set a timeout for the specified milliseconds, and resolve the promise once the timeout is finished.
848
     * @param milliseconds the minimum number of milliseconds to sleep for
849
     */
850
    public sleep(milliseconds: number) {
851
        return new Promise((resolve) => {
1,114✔
852
            //if milliseconds is 0, don't actually timeout (improves unit test throughput)
853
            if (milliseconds === 0) {
1,114✔
854
                process.nextTick(resolve);
980✔
855
            } else {
856
                setTimeout(resolve, milliseconds);
134✔
857
            }
858
        });
859
    }
860

861
    /**
862
     * Given an array, map and then flatten
863
     * @param array the array to flatMap over
864
     * @param callback a function that is called for every array item
865
     */
866
    public flatMap<T, R>(array: T[], callback: (arg: T) => R[]): R[] {
867
        return Array.prototype.concat.apply([], array.map(callback));
73✔
868
    }
869

870
    /**
871
     * Determines if the position is greater than the range. This means
872
     * the position does not touch the range, and has a position greater than the end
873
     * of the range. A position that touches the last line/char of a range is considered greater
874
     * than the range, because the `range.end` is EXclusive
875
     */
876
    public positionIsGreaterThanRange(position: Position, range: Range) {
877

878
        //if the position is a higher line than the range
879
        if (position.line > range.end.line) {
1,304✔
880
            return true;
1,136✔
881
        } else if (position.line < range.end.line) {
168✔
882
            return false;
14✔
883
        }
884
        //they are on the same line
885

886
        //if the position's char is greater than or equal to the range's
887
        if (position.character >= range.end.character) {
154✔
888
            return true;
145✔
889
        } else {
890
            return false;
9✔
891
        }
892
    }
893

894
    /**
895
     * Get a location object back by extracting location information from other objects that contain location
896
     */
897
    public getRange(startObj: { range: Range }, endObj: { range: Range }): Range {
898
        if (!startObj?.range || !endObj?.range) {
213!
899
            return undefined;
16✔
900
        }
901
        return util.createRangeFromPositions(startObj.range?.start, endObj.range?.end);
197!
902
    }
903

904
    /**
905
     * If the two items both start on the same line
906
     */
907
    public sameStartLine(first: { range: Range }, second: { range: Range }) {
908
        if (first && second && (first.range !== undefined) && (second.range !== undefined) &&
×
909
            first.range.start.line === second.range.start.line
910
        ) {
911
            return true;
×
912
        } else {
913
            return false;
×
914
        }
915
    }
916

917
    /**
918
     * If the two items have lines that touch
919
     */
920
    public linesTouch(first: { range?: Range | undefined }, second: { range?: Range | undefined }) {
921
        if (first && second && (first.range !== undefined) && (second.range !== undefined) && (
176✔
922
            first.range.start.line === second.range.start.line ||
923
            first.range.start.line === second.range.end.line ||
924
            first.range.end.line === second.range.start.line ||
925
            first.range.end.line === second.range.end.line
926
        )) {
927
            return true;
81✔
928
        } else {
929
            return false;
95✔
930
        }
931
    }
932

933
    /**
934
     * Given text with (or without) dots separating text, get the rightmost word.
935
     * (i.e. given "A.B.C", returns "C". or "B" returns "B because there's no dot)
936
     */
937
    public getTextAfterFinalDot(name: string) {
938
        if (name) {
188!
939
            let parts = name.split('.');
188✔
940
            if (parts.length > 0) {
188!
941
                return parts[parts.length - 1];
188✔
942
            }
943
        }
944
    }
945

946
    /**
947
     * Find a script import that the current position touches, or undefined if not found
948
     */
949
    public getScriptImportAtPosition(scriptImports: FileReference[], position: Position): FileReference | undefined {
950
        let scriptImport = scriptImports.find((x) => {
77✔
951
            return x.filePathRange &&
4✔
952
                x.filePathRange.start.line === position.line &&
953
                //column between start and end
954
                position.character >= x.filePathRange.start.character &&
955
                position.character <= x.filePathRange.end.character;
956
        });
957
        return scriptImport;
77✔
958
    }
959

960
    /**
961
     * Given the class name text, return a namespace-prefixed name.
962
     * If the name already has a period in it, or the namespaceName was not provided, return the class name as is.
963
     * If the name does not have a period, and a namespaceName was provided, return the class name prepended by the namespace name.
964
     * If no namespace is provided, return the `className` unchanged.
965
     */
966
    public getFullyQualifiedClassName(className: string, namespaceName?: string) {
967
        if (className?.includes('.') === false && namespaceName) {
113,060✔
968
            return `${namespaceName}.${className}`;
246✔
969
        } else {
970
            return className;
112,814✔
971
        }
972
    }
973

974
    public splitIntoLines(string: string) {
975
        return string.split(/\r?\n/g);
169✔
976
    }
977

978
    public getTextForRange(string: string | string[], range: Range): string {
979
        let lines: string[];
980
        if (Array.isArray(string)) {
171✔
981
            lines = string;
170✔
982
        } else {
983
            lines = this.splitIntoLines(string);
1✔
984
        }
985

986
        const start = range.start;
171✔
987
        const end = range.end;
171✔
988

989
        let endCharacter = end.character;
171✔
990
        // If lines are the same we need to subtract out our new starting position to make it work correctly
991
        if (start.line === end.line) {
171✔
992
            endCharacter -= start.character;
1✔
993
        }
994

995
        let rangeLines = [lines[start.line].substring(start.character)];
171✔
996
        for (let i = start.line + 1; i <= end.line; i++) {
171✔
997
            rangeLines.push(lines[i]);
170✔
998
        }
999
        const lastLine = rangeLines.pop();
171✔
1000
        if (lastLine !== undefined) {
171!
1001
            rangeLines.push(lastLine.substring(0, endCharacter));
171✔
1002
        }
1003
        return rangeLines.join('\n');
171✔
1004
    }
1005

1006
    /**
1007
     * Helper for creating `Location` objects. Prefer using this function because vscode-languageserver's `Location.create()` is significantly slower at scale
1008
     */
1009
    public createLocation(uri: string, range: Range): Location {
1010
        return {
168✔
1011
            uri: uri,
1012
            range: range
1013
        };
1014
    }
1015

1016
    /**
1017
     * Helper for creating `Range` objects. Prefer using this function because vscode-languageserver's `Range.create()` is significantly slower
1018
     */
1019
    public createRange(startLine: number, startCharacter: number, endLine: number, endCharacter: number): Range {
1020
        return {
77,800✔
1021
            start: {
1022
                line: startLine,
1023
                character: startCharacter
1024
            },
1025
            end: {
1026
                line: endLine,
1027
                character: endCharacter
1028
            }
1029
        };
1030
    }
1031

1032
    /**
1033
     * Create a `Range` from two `Position`s
1034
     */
1035
    public createRangeFromPositions(startPosition: Position, endPosition: Position): Range {
1036
        return {
15,850✔
1037
            start: {
1038
                line: startPosition.line,
1039
                character: startPosition.character
1040
            },
1041
            end: {
1042
                line: endPosition.line,
1043
                character: endPosition.character
1044
            }
1045
        };
1046
    }
1047

1048
    /**
1049
     * Clone a range
1050
     */
1051
    public cloneRange(range: Range) {
1052
        if (range) {
1,313✔
1053
            return this.createRange(range.start.line, range.start.character, range.end.line, range.end.character);
1,289✔
1054
        } else {
1055
            return range;
24✔
1056
        }
1057
    }
1058

1059
    /**
1060
     * Clone every token
1061
     */
1062
    public cloneToken<T extends Token>(token: T) {
1063
        if (token) {
1,474✔
1064
            const result = {
1,191✔
1065
                kind: token.kind,
1066
                range: this.cloneRange(token.range),
1067
                text: token.text,
1068
                isReserved: token.isReserved,
1069
                leadingWhitespace: token.leadingWhitespace
1070
            } as T;
1071
            //handle those tokens that have charCode
1072
            if ('charCode' in token) {
1,191✔
1073
                (result as any).charCode = (token as any).charCode;
3✔
1074
            }
1075
            return result;
1,191✔
1076
        } else {
1077
            return token;
283✔
1078
        }
1079
    }
1080

1081
    /**
1082
     * Given a list of ranges, create a range that starts with the first non-null lefthand range, and ends with the first non-null
1083
     * righthand range. Returns undefined if none of the items have a range.
1084
     */
1085
    public createBoundingRange(...locatables: Array<{ range?: Range } | null | undefined>): Range | undefined {
1086
        let leftmostRange: Range | undefined;
1087
        let rightmostRange: Range | undefined;
1088

1089
        for (let i = 0; i < locatables.length; i++) {
16,393✔
1090
            //set the leftmost non-null-range item
1091
            const left = locatables[i];
20,496✔
1092
            //the range might be a getter, so access it exactly once
1093
            const leftRange = left?.range;
20,496✔
1094
            if (!leftmostRange && leftRange) {
20,496✔
1095
                leftmostRange = leftRange;
15,482✔
1096
            }
1097

1098
            //set the rightmost non-null-range item
1099
            const right = locatables[locatables.length - 1 - i];
20,496✔
1100
            //the range might be a getter, so access it exactly once
1101
            const rightRange = right?.range;
20,496✔
1102
            if (!rightmostRange && rightRange) {
20,496✔
1103
                rightmostRange = rightRange;
15,482✔
1104
            }
1105

1106
            //if we have both sides, quit
1107
            if (leftmostRange && rightmostRange) {
20,496✔
1108
                break;
15,482✔
1109
            }
1110
        }
1111
        if (leftmostRange) {
16,393✔
1112
            //if we don't have a rightmost range, use the leftmost range for both the start and end
1113
            return this.createRangeFromPositions(
15,482✔
1114
                leftmostRange.start,
1115
                rightmostRange ? rightmostRange.end : leftmostRange.end);
15,482!
1116
        } else {
1117
            return undefined;
911✔
1118
        }
1119
    }
1120

1121
    /**
1122
     * Create a `Position` object. Prefer this over `Position.create` for performance reasons
1123
     */
1124
    public createPosition(line: number, character: number) {
1125
        return {
257✔
1126
            line: line,
1127
            character: character
1128
        };
1129
    }
1130

1131
    /**
1132
     * Convert a list of tokens into a string, including their leading whitespace
1133
     */
1134
    public tokensToString(tokens: Token[]) {
1135
        let result = '';
1✔
1136
        //skip iterating the final token
1137
        for (let token of tokens) {
1✔
1138
            result += token.leadingWhitespace + token.text;
16✔
1139
        }
1140
        return result;
1✔
1141
    }
1142

1143
    /**
1144
     * Convert a token into a BscType
1145
     */
1146
    public tokenToBscType(token: Token, allowCustomType = true) {
4,143✔
1147
        // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
1148
        switch (token.kind) {
4,539✔
1149
            case TokenKind.Boolean:
8,174✔
1150
                return new BooleanType(token.text);
34✔
1151
            case TokenKind.True:
1152
            case TokenKind.False:
1153
                return new BooleanType();
766✔
1154
            case TokenKind.Double:
1155
                return new DoubleType(token.text);
15✔
1156
            case TokenKind.DoubleLiteral:
1157
                return new DoubleType();
27✔
1158
            case TokenKind.Dynamic:
1159
                return new DynamicType(token.text);
80✔
1160
            case TokenKind.Float:
1161
                return new FloatType(token.text);
25✔
1162
            case TokenKind.FloatLiteral:
1163
                return new FloatType();
55✔
1164
            case TokenKind.Function:
1165
                //TODO should there be a more generic function type without a signature that's assignable to all other function types?
1166
                return new FunctionType(new DynamicType(token.text));
18✔
1167
            case TokenKind.Integer:
1168
                return new IntegerType(token.text);
175✔
1169
            case TokenKind.IntegerLiteral:
1170
                return new IntegerType();
1,372✔
1171
            case TokenKind.Invalid:
1172
                return new InvalidType(token.text);
59✔
1173
            case TokenKind.LongInteger:
1174
                return new LongIntegerType(token.text);
15✔
1175
            case TokenKind.LongIntegerLiteral:
1176
                return new LongIntegerType();
2✔
1177
            case TokenKind.Object:
1178
                return new ObjectType(token.text);
57✔
1179
            case TokenKind.String:
1180
                return new StringType(token.text);
458✔
1181
            case TokenKind.StringLiteral:
1182
            case TokenKind.TemplateStringExpressionBegin:
1183
            case TokenKind.TemplateStringExpressionEnd:
1184
            case TokenKind.TemplateStringQuasi:
1185
                return new StringType();
1,152✔
1186
            case TokenKind.Void:
1187
                return new VoidType(token.text);
36✔
1188
            case TokenKind.Identifier:
1189
                switch (token.text.toLowerCase()) {
185✔
1190
                    case 'boolean':
36!
1191
                        return new BooleanType(token.text);
×
1192
                    case 'double':
1193
                        return new DoubleType(token.text);
×
1194
                    case 'float':
1195
                        return new FloatType(token.text);
×
1196
                    case 'function':
1197
                        return new FunctionType(new DynamicType(token.text));
×
1198
                    case 'integer':
1199
                        return new IntegerType(token.text);
24✔
1200
                    case 'invalid':
1201
                        return new InvalidType(token.text);
×
1202
                    case 'longinteger':
1203
                        return new LongIntegerType(token.text);
×
1204
                    case 'object':
1205
                        return new ObjectType(token.text);
2✔
1206
                    case 'string':
1207
                        return new StringType(token.text);
8✔
1208
                    case 'void':
1209
                        return new VoidType(token.text);
2✔
1210
                }
1211
                if (allowCustomType) {
149✔
1212
                    return new CustomType(token.text);
145✔
1213
                }
1214
        }
1215
    }
1216

1217
    /**
1218
     * Get the extension for the given file path. Basically the part after the final dot, except for
1219
     * `d.bs` which is treated as single extension
1220
     */
1221
    public getExtension(filePath: string) {
1222
        filePath = filePath.toLowerCase();
1,610✔
1223
        if (filePath.endsWith('.d.bs')) {
1,610✔
1224
            return '.d.bs';
27✔
1225
        } else {
1226
            const idx = filePath.lastIndexOf('.');
1,583✔
1227
            if (idx > -1) {
1,583✔
1228
                return filePath.substring(idx);
1,577✔
1229
            }
1230
        }
1231
    }
1232

1233
    /**
1234
     * Load and return the list of plugins
1235
     */
1236
    public loadPlugins(cwd: string, pathOrModules: string[], onError?: (pathOrModule: string, err: Error) => void): Plugin[] {
1237
        const logger = createLogger();
124✔
1238
        return pathOrModules.reduce<Plugin[]>((acc, pathOrModule) => {
124✔
1239
            if (typeof pathOrModule === 'string') {
8!
1240
                try {
8✔
1241
                    const loaded = requireRelative(pathOrModule, cwd);
8✔
1242
                    const theExport: Plugin | PluginFactory = loaded.default ? loaded.default : loaded;
8✔
1243

1244
                    let plugin: Plugin | undefined;
1245

1246
                    // legacy plugins returned a plugin object. If we find that, then add a warning
1247
                    if (typeof theExport === 'object') {
8✔
1248
                        logger.warn(`Plugin "${pathOrModule}" was loaded as a singleton. Please contact the plugin author to update to the factory pattern.\n`);
2✔
1249
                        plugin = theExport;
2✔
1250

1251
                        // the official plugin format is a factory function that returns a new instance of a plugin.
1252
                    } else if (typeof theExport === 'function') {
6!
1253
                        plugin = theExport({
6✔
1254
                            version: this.getBrighterScriptVersion()
1255
                        });
1256
                    } else {
1257
                        //this should never happen; somehow an invalid plugin has made it into here
1258
                        throw new Error(`TILT: Encountered an invalid plugin: ${String(plugin)}`);
×
1259
                    }
1260

1261
                    if (!plugin.name) {
8✔
1262
                        plugin.name = pathOrModule;
1✔
1263
                    }
1264
                    acc.push(plugin);
8✔
1265
                } catch (err: any) {
1266
                    if (onError) {
×
1267
                        onError(pathOrModule, err);
×
1268
                    } else {
1269
                        throw err;
×
1270
                    }
1271
                }
1272
            }
1273
            return acc;
8✔
1274
        }, []);
1275
    }
1276

1277
    /**
1278
     * Gathers expressions, variables, and unique names from an expression.
1279
     * This is mostly used for the ternary expression
1280
     */
1281
    public getExpressionInfo(expression: Expression, file: BrsFile): ExpressionInfo {
1282
        const expressions = [expression];
58✔
1283
        const variableExpressions = [] as VariableExpression[];
58✔
1284
        const uniqueVarNames = new Set<string>();
58✔
1285

1286
        function expressionWalker(expression) {
1287
            if (isExpression(expression)) {
162✔
1288
                expressions.push(expression);
158✔
1289
            }
1290
            if (isVariableExpression(expression)) {
162✔
1291
                variableExpressions.push(expression);
55✔
1292
                uniqueVarNames.add(expression.name.text);
55✔
1293
            }
1294
        }
1295

1296
        // Collect all expressions. Most of these expressions are fairly small so this should be quick!
1297
        // This should only be called during transpile time and only when we actually need it.
1298
        expression?.walk(expressionWalker, {
58✔
1299
            walkMode: WalkMode.visitExpressions
1300
        });
1301

1302
        //handle the expression itself (for situations when expression is a VariableExpression)
1303
        expressionWalker(expression);
58✔
1304

1305
        const scope = file.program.getFirstScopeForFile(file);
58✔
1306
        let filteredVarNames = [...uniqueVarNames];
58✔
1307
        if (scope) {
58!
1308
            filteredVarNames = filteredVarNames.filter((varName: string) => {
58✔
1309
                const varNameLower = varName.toLowerCase();
53✔
1310
                // TODO: include namespaces in this filter
1311
                return !scope.getEnumMap().has(varNameLower) &&
53✔
1312
                    !scope.getConstMap().has(varNameLower);
1313
            });
1314
        }
1315

1316
        return { expressions: expressions, varExpressions: variableExpressions, uniqueVarNames: filteredVarNames };
58✔
1317
    }
1318

1319

1320
    /**
1321
     * Create a SourceNode that maps every line to itself. Useful for creating maps for files
1322
     * that haven't changed at all, but we still need the map
1323
     */
1324
    public simpleMap(source: string, src: string) {
1325
        //create a source map from the original source code
1326
        let chunks = [] as (SourceNode | string)[];
5✔
1327
        let lines = src.split(/\r?\n/g);
5✔
1328
        for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
5✔
1329
            let line = lines[lineIndex];
26✔
1330
            chunks.push(
26✔
1331
                lineIndex > 0 ? '\n' : '',
26✔
1332
                new SourceNode(lineIndex + 1, 0, source, line)
1333
            );
1334
        }
1335
        return new SourceNode(null, null, source, chunks);
5✔
1336
    }
1337

1338
    /**
1339
     * Creates a new SGAttribute object, but keeps the existing Range references (since those shouldn't ever get changed directly)
1340
     */
1341
    public cloneSGAttribute(attr: SGAttribute, value: string) {
1342
        return {
15✔
1343
            key: {
1344
                text: attr.key.text,
1345
                range: attr.range
1346
            },
1347
            value: {
1348
                text: value,
1349
                range: attr.value.range
1350
            },
1351
            range: attr.range
1352
        } as SGAttribute;
1353
    }
1354

1355
    private isWindows = process.platform === 'win32';
1✔
1356
    private standardizePathCache = new Map<string, string>();
1✔
1357

1358
    /**
1359
     * Converts a path into a standardized format (drive letter to lower, remove extra slashes, use single slash type, resolve relative parts, etc...)
1360
     */
1361
    public standardizePath(thePath: string): string {
1362
        //if we have the value in cache already, return it
1363
        if (this.standardizePathCache.has(thePath)) {
15,260✔
1364
            return this.standardizePathCache.get(thePath);
13,781✔
1365
        }
1366
        const originalPath = thePath;
1,479✔
1367

1368
        if (typeof thePath !== 'string') {
1,479!
1369
            return thePath;
×
1370
        }
1371

1372
        //windows path.normalize will convert all slashes to backslashes and remove duplicates
1373
        if (this.isWindows) {
1,479✔
1374
            thePath = path.win32.normalize(thePath);
1,464✔
1375
        } else {
1376
            //replace all windows or consecutive slashes with path.sep
1377
            thePath = thePath.replace(/[\/\\]+/g, '/');
15✔
1378

1379
            // only use path.normalize if dots are present since it's expensive
1380
            if (thePath.includes('./')) {
15!
1381
                thePath = path.posix.normalize(thePath);
×
1382
            }
1383
        }
1384

1385
        // Lowercase drive letter on Windows-like paths (e.g., "C:/...")
1386
        if (thePath.charCodeAt(1) === 58 /* : */) {
1,479✔
1387
            // eslint-disable-next-line no-var
1388
            var firstChar = thePath.charCodeAt(0);
858✔
1389
            if (firstChar >= 65 && firstChar <= 90) {
858✔
1390
                thePath = String.fromCharCode(firstChar + 32) + thePath.slice(1);
77✔
1391
            }
1392
        }
1393
        this.standardizePathCache.set(originalPath, thePath);
1,479✔
1394
        return thePath;
1,479✔
1395
    }
1396

1397
    /**
1398
     * Copy the version of bslib from local node_modules to the staging folder
1399
     */
1400
    public async copyBslibToStaging(stagingDir: string, bslibDestinationDir = 'source') {
1✔
1401
        //copy bslib to the output directory
1402
        await fsExtra.ensureDir(standardizePath(`${stagingDir}/${bslibDestinationDir}`));
33✔
1403
        // eslint-disable-next-line
1404
        const bslib = require('@rokucommunity/bslib');
33✔
1405
        let source = bslib.source as string;
33✔
1406

1407
        //apply the `bslib_` prefix to the functions
1408
        let match: RegExpExecArray | null;
1409
        const positions = [] as number[];
33✔
1410
        const regexp = /^(\s*(?:function|sub)\s+)([a-z0-9_]+)/mg;
33✔
1411
        // eslint-disable-next-line no-cond-assign
1412
        while (match = regexp.exec(source)) {
33✔
1413
            positions.push(match.index + match[1].length);
99✔
1414
        }
1415

1416
        for (let i = positions.length - 1; i >= 0; i--) {
33✔
1417
            const position = positions[i];
99✔
1418
            source = source.slice(0, position) + 'bslib_' + source.slice(position);
99✔
1419
        }
1420
        await fsExtra.writeFile(`${stagingDir}/${bslibDestinationDir}/bslib.brs`, source);
33✔
1421
    }
1422

1423
    /**
1424
     * Given a Diagnostic or BsDiagnostic, return a deep clone of the diagnostic.
1425
     * @param diagnostic the diagnostic to clone
1426
     * @param relatedInformationFallbackLocation a default location to use for all `relatedInformation` entries that are missing a location
1427
     */
1428
    public toDiagnostic(diagnostic: Diagnostic | BsDiagnostic, relatedInformationFallbackLocation: string): Diagnostic {
1429
        return {
131✔
1430
            severity: diagnostic.severity,
1431
            range: diagnostic.range,
1432
            message: diagnostic.message,
1433
            relatedInformation: diagnostic.relatedInformation?.map(x => {
393✔
1434

1435
                //clone related information just in case a plugin added circular ref info here
1436
                const clone = { ...x };
40✔
1437
                if (!clone.location) {
40✔
1438
                    // use the fallback location if available
1439
                    if (relatedInformationFallbackLocation) {
2✔
1440
                        clone.location = util.createLocation(relatedInformationFallbackLocation, diagnostic.range);
1✔
1441
                    } else {
1442
                        //remove this related information so it doesn't bring crash the language server
1443
                        return undefined;
1✔
1444
                    }
1445
                }
1446
                return clone;
39✔
1447
                //filter out null relatedInformation items
1448
            }).filter((x): x is DiagnosticRelatedInformation => Boolean(x)),
40✔
1449
            code: diagnostic.code,
1450
            source: diagnostic.source ?? 'brs'
393✔
1451
        };
1452
    }
1453

1454
    /**
1455
     * Get the first locatable item found at the specified position
1456
     * @param locatables an array of items that have a `range` property
1457
     * @param position the position that the locatable must contain
1458
     */
1459
    public getFirstLocatableAt(locatables: Locatable[], position: Position) {
1460
        for (let token of locatables) {
×
1461
            if (util.rangeContains(token.range, position)) {
×
1462
                return token;
×
1463
            }
1464
        }
1465
    }
1466

1467
    /**
1468
     * Sort an array of objects that have a Range
1469
     */
1470
    public sortByRange<T extends Locatable>(locatables: T[]) {
1471
        //sort the tokens by range
1472
        return locatables?.sort((a, b) => {
35!
1473
            //start line
1474
            if (a.range.start.line < b.range.start.line) {
80✔
1475
                return -1;
11✔
1476
            }
1477
            if (a.range.start.line > b.range.start.line) {
69✔
1478
                return 1;
13✔
1479
            }
1480
            //start char
1481
            if (a.range.start.character < b.range.start.character) {
56✔
1482
                return -1;
4✔
1483
            }
1484
            if (a.range.start.character > b.range.start.character) {
52!
1485
                return 1;
52✔
1486
            }
1487
            //end line
1488
            if (a.range.end.line < b.range.end.line) {
×
1489
                return -1;
×
1490
            }
1491
            if (a.range.end.line > b.range.end.line) {
×
1492
                return 1;
×
1493
            }
1494
            //end char
1495
            if (a.range.end.character < b.range.end.character) {
×
1496
                return -1;
×
1497
            } else if (a.range.end.character > b.range.end.character) {
×
1498
                return 1;
×
1499
            }
1500
            return 0;
×
1501
        });
1502
    }
1503

1504
    /**
1505
     * Split the given text and return ranges for each chunk.
1506
     * Only works for single-line strings
1507
     */
1508
    public splitGetRange(separator: string, text: string, range: Range) {
1509
        const chunks = text.split(separator);
3✔
1510
        const result = [] as Array<{ text: string; range: Range }>;
3✔
1511
        let offset = 0;
3✔
1512
        for (let chunk of chunks) {
3✔
1513
            //only keep nonzero chunks
1514
            if (chunk.length > 0) {
8✔
1515
                result.push({
7✔
1516
                    text: chunk,
1517
                    range: this.createRange(
1518
                        range.start.line,
1519
                        range.start.character + offset,
1520
                        range.end.line,
1521
                        range.start.character + offset + chunk.length
1522
                    )
1523
                });
1524
            }
1525
            offset += chunk.length + separator.length;
8✔
1526
        }
1527
        return result;
3✔
1528
    }
1529

1530
    /**
1531
     * Wrap the given code in a markdown code fence (with the language)
1532
     */
1533
    public mdFence(code: string, language = '') {
×
1534
        return '```' + language + '\n' + code + '\n```';
35✔
1535
    }
1536

1537
    /**
1538
     * Gets each part of the dotted get.
1539
     * @param node any ast expression
1540
     * @returns an array of the parts of the dotted get. If not fully a dotted get, then returns undefined
1541
     */
1542
    public getAllDottedGetParts(node: Expression | Statement): Identifier[] | undefined {
1543
        const parts: Identifier[] = [];
101✔
1544
        let nextPart: AstNode | undefined = node;
101✔
1545
        while (nextPart) {
101✔
1546
            if (isAssignmentStatement(node)) {
151✔
1547
                return [node.name];
10✔
1548
            } else if (isDottedGetExpression(nextPart)) {
141✔
1549
                parts.push(nextPart?.name);
48!
1550
                nextPart = nextPart.obj;
48✔
1551
            } else if (isNamespacedVariableNameExpression(nextPart)) {
93✔
1552
                nextPart = nextPart.expression;
2✔
1553
            } else if (isVariableExpression(nextPart)) {
91✔
1554
                parts.push(nextPart?.name);
45!
1555
                break;
45✔
1556
            } else if (isFunctionParameterExpression(nextPart)) {
46✔
1557
                return [nextPart.name];
10✔
1558
            } else {
1559
                //we found a non-DottedGet expression, so return because this whole operation is invalid.
1560
                return undefined;
36✔
1561
            }
1562
        }
1563
        return parts.reverse();
45✔
1564
    }
1565

1566
    /**
1567
     * Break an expression into each part.
1568
     */
1569
    public splitExpression(expression: Expression) {
1570
        const parts: Expression[] = [expression];
1,433✔
1571
        let nextPart = expression;
1,433✔
1572
        while (nextPart) {
1,433✔
1573
            if (isDottedGetExpression(nextPart) || isIndexedGetExpression(nextPart) || isXmlAttributeGetExpression(nextPart)) {
1,965✔
1574
                nextPart = nextPart.obj;
325✔
1575

1576
            } else if (isCallExpression(nextPart) || isCallfuncExpression(nextPart)) {
1,640✔
1577
                nextPart = nextPart.callee;
207✔
1578

1579
            } else if (isNamespacedVariableNameExpression(nextPart)) {
1,433!
1580
                nextPart = nextPart.expression;
×
1581
            } else {
1582
                break;
1,433✔
1583
            }
1584
            parts.unshift(nextPart);
532✔
1585
        }
1586
        return parts;
1,433✔
1587
    }
1588

1589
    /**
1590
     * Break an expression into each part, and return any VariableExpression or DottedGet expresisons from left-to-right.
1591
     */
1592
    public getDottedGetPath(expression: Expression): [VariableExpression, ...DottedGetExpression[]] {
1593
        let parts: Expression[] = [];
2,602✔
1594
        let nextPart = expression;
2,602✔
1595
        while (nextPart) {
2,602✔
1596
            if (isDottedGetExpression(nextPart)) {
3,544✔
1597
                parts.unshift(nextPart);
429✔
1598
                nextPart = nextPart.obj;
429✔
1599

1600
            } else if (isIndexedGetExpression(nextPart) || isXmlAttributeGetExpression(nextPart)) {
3,115✔
1601
                nextPart = nextPart.obj;
60✔
1602
                parts = [];
60✔
1603

1604
            } else if (isCallExpression(nextPart) || isCallfuncExpression(nextPart)) {
3,055✔
1605
                nextPart = nextPart.callee;
389✔
1606
                parts = [];
389✔
1607

1608
            } else if (isNewExpression(nextPart)) {
2,666✔
1609
                nextPart = nextPart.call.callee;
32✔
1610
                parts = [];
32✔
1611

1612
            } else if (isNamespacedVariableNameExpression(nextPart)) {
2,634✔
1613
                nextPart = nextPart.expression;
32✔
1614

1615
            } else if (isVariableExpression(nextPart)) {
2,602✔
1616
                parts.unshift(nextPart);
806✔
1617
                break;
806✔
1618
            } else {
1619
                parts = [];
1,796✔
1620
                break;
1,796✔
1621
            }
1622
        }
1623
        return parts as any;
2,602✔
1624
    }
1625

1626
    /**
1627
     * Returns an integer if valid, or undefined. Eliminates checking for NaN
1628
     */
1629
    public parseInt(value: any) {
1630
        const result = parseInt(value);
36✔
1631
        if (!isNaN(result)) {
36✔
1632
            return result;
31✔
1633
        } else {
1634
            return undefined;
5✔
1635
        }
1636
    }
1637

1638
    /**
1639
     * Converts a range to a string in the format 1:2-3:4
1640
     */
1641
    public rangeToString(range: Range) {
1642
        return `${range?.start?.line}:${range?.start?.character}-${range?.end?.line}:${range?.end?.character}`;
195!
1643
    }
1644

1645
    public validateTooDeepFile(file: (BrsFile | XmlFile)) {
1646
        //find any files nested too deep
1647
        let pkgPath = file.pkgPath ?? (file.pkgPath as any).toString();
1,031!
1648
        let rootFolder = pkgPath.replace(/^pkg:/, '').split(/[\\\/]/)[0].toLowerCase();
1,031✔
1649

1650
        if (isBrsFile(file) && rootFolder !== 'source') {
1,031✔
1651
            return;
119✔
1652
        }
1653

1654
        if (isXmlFile(file) && rootFolder !== 'components') {
912!
1655
            return;
×
1656
        }
1657

1658
        let fileDepth = this.getParentDirectoryCount(pkgPath);
912✔
1659
        if (fileDepth >= 8) {
912✔
1660
            file.addDiagnostics([{
3✔
1661
                ...DiagnosticMessages.detectedTooDeepFileSource(fileDepth),
1662
                file: file,
1663
                range: this.createRange(0, 0, 0, Number.MAX_VALUE)
1664
            }]);
1665
        }
1666
    }
1667

1668
    /**
1669
     * Execute dispose for a series of disposable items
1670
     * @param disposables a list of functions or disposables
1671
     */
1672
    public applyDispose(disposables: DisposableLike[]) {
1673
        for (const disposable of disposables ?? []) {
6!
1674
            if (typeof disposable === 'function') {
12!
1675
                disposable();
12✔
1676
            } else {
1677
                disposable?.dispose?.();
×
1678
            }
1679
        }
1680
    }
1681

1682
    /**
1683
     * Race a series of promises, and return the first one that resolves AND matches the matcher function.
1684
     * If all of the promises reject, then this will emit an AggregatreError with all of the errors.
1685
     * If at least one promise resolves, then this will log all of the errors to the console
1686
     * If at least one promise resolves but none of them match the matcher, then this will return undefined.
1687
     * @param promises all of the promises to race
1688
     * @param matcher a function that should return true if this value should be kept. Returning any value other than true means `false`
1689
     * @returns the first resolved value that matches the matcher, or undefined if none of them match
1690
     */
1691
    public async promiseRaceMatch<T>(promises: MaybePromise<T>[], matcher: (value: T) => boolean) {
1692
        const workingPromises = [
31✔
1693
            ...promises
1694
        ];
1695

1696
        const results: Array<{ value: T; index: number } | { error: Error; index: number }> = [];
31✔
1697
        let returnValue: T;
1698

1699
        while (workingPromises.length > 0) {
31✔
1700
            //race the promises. If any of them resolve, evaluate it against the matcher. If that passes, return the value. otherwise, eliminate this promise and try again
1701
            const result = await Promise.race(
37✔
1702
                workingPromises.map((promise, i) => {
1703
                    return Promise.resolve(promise)
54✔
1704
                        .then(value => ({ value: value, index: i }))
46✔
1705
                        .catch(error => ({ error: error, index: i }));
7✔
1706
                })
1707
            );
1708
            results.push(result);
37✔
1709
            //if we got a value and it matches the matcher, return it
1710
            if ('value' in result && matcher?.(result.value) === true) {
37✔
1711
                returnValue = result.value;
27✔
1712
                break;
27✔
1713
            }
1714

1715
            //remove this non-matched (or errored) promise from the list and try again
1716
            workingPromises.splice(result.index, 1);
10✔
1717
        }
1718

1719
        const errors = (results as Array<{ error: Error }>)
31✔
1720
            .filter(x => 'error' in x)
37✔
1721
            .map(x => x.error);
4✔
1722

1723
        //if all of them crashed, then reject
1724
        if (promises.length > 0 && errors.length === promises.length) {
31✔
1725
            throw new AggregateError(errors, 'All requests failed. First error message: ' + errors[0].message);
1✔
1726
        } else {
1727
            //log all of the errors
1728
            for (const error of errors) {
30✔
1729
                console.error(error);
1✔
1730
            }
1731
        }
1732

1733
        //return the matched value, or undefined if there wasn't one
1734
        return returnValue;
30✔
1735
    }
1736

1737
    /**
1738
     * Wraps SourceNode's constructor to be compatible with the TranspileResult type
1739
     */
1740
    public sourceNodeFromTranspileResult(
1741
        line: number | null,
1742
        column: number | null,
1743
        source: string | null,
1744
        chunks?: string | SourceNode | TranspileResult,
1745
        name?: string
1746
    ): SourceNode {
1747
        // we can use a typecast rather than actually transforming the data because SourceNode
1748
        // accepts a more permissive type than its typedef states
1749
        return new SourceNode(line, column, source, chunks as any, name);
2,009✔
1750
    }
1751

1752
    public isBuiltInType(typeName: string) {
1753
        const typeNameLower = typeName.toLowerCase();
61✔
1754
        if (typeNameLower.startsWith('rosgnode')) {
61✔
1755
            // NOTE: this is unsafe and only used to avoid validation errors in backported v1 type syntax
1756
            return true;
9✔
1757
        }
1758
        return components[typeNameLower] || interfaces[typeNameLower] || events[typeNameLower];
52✔
1759
    }
1760

1761
    /**
1762
     * Get a short name that can be used to reference the project in logs. (typically something like `prj1`, `prj8`, etc...)
1763
     */
1764
    public getProjectLogName(config: { projectNumber: number }) {
1765
        //if we have a project number, use it
1766
        if (config?.projectNumber !== undefined) {
752✔
1767
            return `prj${config.projectNumber}`;
170✔
1768
        }
1769
        //just return empty string so log functions don't crash with undefined project numbers
1770
        return '';
582✔
1771
    }
1772
}
1773

1774
/**
1775
 * A tagged template literal function for standardizing the path. This has to be defined as standalone function since it's a tagged template literal function,
1776
 * we can't use `object.tag` syntax.
1777
 */
1778
export function standardizePath(stringParts, ...expressions: any[]) {
1✔
1779
    let result: string[] = [];
8,137✔
1780
    for (let i = 0; i < stringParts?.length; i++) {
8,137!
1781
        result.push(stringParts[i], expressions[i]);
22,518✔
1782
    }
1783
    return util.standardizePath(
8,137✔
1784
        result.join('')
1785
    );
1786
}
1787

1788
export let util = new Util();
1✔
1789
export default util;
1✔
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