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

rokucommunity / brighterscript / #15637

28 Apr 2026 03:19PM UTC coverage: 88.999% (-0.01%) from 89.009%
#15637

push

web-flow
Merge b388c7d11 into 9cb905446

8258 of 9776 branches covered (84.47%)

Branch coverage included in aggregate %.

16 of 16 new or added lines in 2 files covered. (100.0%)

12 existing lines in 2 files now uncovered.

10463 of 11259 relevant lines covered (92.93%)

2026.41 hits per line

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

87.73
/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, SourceMapConsumer } from 'source-map';
1✔
33
import type { RawSourceMap, SourceMapGenerator } from 'source-map';
34
import type { SGAttribute } from './parser/SGTypes';
35
import * as requireRelative from 'require-relative';
1✔
36
import type { BrsFile } from './files/BrsFile';
37
import type { XmlFile } from './files/XmlFile';
38
import type { AstNode, Expression, Statement } from './parser/AstNode';
39
import { components, events, interfaces } from './roku-types';
1✔
40

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

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

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

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

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

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

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

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

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

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

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

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

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

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

235
            let projectFileCwd = path.dirname(configFilePath);
71✔
236

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

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

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

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

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

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

308
        let result: T;
309
        let err;
310

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

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

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

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

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

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

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

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

368
        let logLevel: LogLevel = LogLevel.log;
1,860✔
369

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

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

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

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

413
        return merged;
1,860✔
414
    }
415

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

430
        rootDir = path.resolve(cwd, rootDir);
1,664✔
431

432
        return rootDir;
1,664✔
433
    }
434

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

442
        for (let callableContainer of callables) {
2,557✔
443
            let lowerName = callableContainer.callable.getName(ParseMode.BrightScript).toLowerCase();
197,804✔
444

445
            //create a new array for this name
446
            const list = result.get(lowerName);
197,804✔
447
            if (list) {
197,804✔
448
                list.push(callableContainer);
10,259✔
449
            } else {
450
                result.set(lowerName, [callableContainer]);
187,545✔
451
            }
452
        }
453
        return result;
2,557✔
454
    }
455

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

615
        // These ranges must intersect
616
        return true;
61✔
617
    }
618

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

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

633
        if (position.line < range.start.line || (position.line === range.start.line && position.character < range.start.character)) {
11,165✔
634
            return -1;
765✔
635
        }
636
        if (position.line > range.end.line || (position.line === range.end.line && position.character > range.end.character)) {
10,400✔
637
            return 1;
7,500✔
638
        }
639
        return 0;
2,900✔
640
    }
641

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

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

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

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

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

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

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

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

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

761

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

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

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

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

794
        if (typedefPath.endsWith('.d.bs')) {
2,392✔
795
            return typedefPath;
1,527✔
796
        } else {
797
            return undefined;
865✔
798
        }
799
    }
800

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1107
            //if we have both sides, quit
1108
            if (leftmostRange && rightmostRange) {
21,754✔
1109
                break;
16,675✔
1110
            }
1111
        }
1112
        if (leftmostRange) {
17,591✔
1113
            //if we don't have a rightmost range, use the leftmost range for both the start and end
1114
            return this.createRangeFromPositions(
16,675✔
1115
                leftmostRange.start,
1116
                rightmostRange ? rightmostRange.end : leftmostRange.end);
16,675!
1117
        } else {
1118
            return undefined;
916✔
1119
        }
1120
    }
1121

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

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

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

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

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

1245
                    let plugin: Plugin | undefined;
1246

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

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

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

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

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

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

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

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

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

1320

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1577
            } else if (isCallExpression(nextPart) || isCallfuncExpression(nextPart)) {
1,810✔
1578
                nextPart = nextPart.callee;
209✔
1579

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

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

1601
            } else if (isIndexedGetExpression(nextPart) || isXmlAttributeGetExpression(nextPart)) {
3,390✔
1602
                nextPart = nextPart.obj;
62✔
1603
                parts = [];
62✔
1604

1605
            } else if (isCallExpression(nextPart) || isCallfuncExpression(nextPart)) {
3,328✔
1606
                nextPart = nextPart.callee;
408✔
1607
                parts = [];
408✔
1608

1609
            } else if (isNewExpression(nextPart)) {
2,920✔
1610
                nextPart = nextPart.call.callee;
33✔
1611
                parts = [];
33✔
1612

1613
            } else if (isNamespacedVariableNameExpression(nextPart)) {
2,887✔
1614
                nextPart = nextPart.expression;
33✔
1615

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

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

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

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

1651
        if (isBrsFile(file) && rootFolder !== 'source') {
1,180✔
1652
            return;
134✔
1653
        }
1654

1655
        if (isXmlFile(file) && rootFolder !== 'components') {
1,046!
1656
            return;
×
1657
        }
1658

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

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

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

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

1700
        while (workingPromises.length > 0) {
31✔
1701
            //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
1702
            const result = await Promise.race(
37✔
1703
                workingPromises.map((promise, i) => {
1704
                    return Promise.resolve(promise)
54✔
1705
                        .then(value => ({ value: value, index: i }))
46✔
1706
                        .catch(error => ({ error: error, index: i }));
7✔
1707
                })
1708
            );
1709
            results.push(result);
37✔
1710
            //if we got a value and it matches the matcher, return it
1711
            if ('value' in result && matcher?.(result.value) === true) {
37✔
1712
                returnValue = result.value;
27✔
1713
                break;
27✔
1714
            }
1715

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

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

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

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

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

1753
    /**
1754
     * Parse the `sourceMappingURL` comment from file contents and resolve it to a RawSourceMap.
1755
     * Handles inline base64 data URIs, absolute paths, relative paths (resolved against srcPath's
1756
     * directory), and falls back to a co-located `<srcPath>.map` file.
1757
     * Supports both BrightScript-style comments (`'//# sourceMappingURL=...`) and XML-style
1758
     * comments (`<!--//# sourceMappingURL=... -->`).
1759
     * Returns undefined if no map can be found.
1760
     */
1761
    public async resolveInputSourceMap(fileContents: string, srcPath: string): Promise<RawSourceMap | undefined> {
1762
        // Match sourceMappingURL - [^\s]+ stops at whitespace (safe, no backtracking risk).
1763
        // Strip any trailing XML comment close (either --> or --!>) that may have been captured
1764
        // when the URL is not followed by a space in an XML comment like <!--//# ...=url-->.
1765
        const match = /['"]?\/\/# sourceMappingURL=([^\s]+)/m.exec(fileContents);
15✔
1766
        if (match) {
15✔
1767
            const url = match[1].replace(/--!?>$/, '').trim();
7✔
1768
            if (url.startsWith('data:')) {
7✔
1769
                // inline base64: data:application/json;base64,<b64>
1770
                const b64Match = /base64,([A-Za-z0-9+/=]+)$/.exec(url);
2✔
1771
                if (b64Match) {
2!
1772
                    return JSON.parse(Buffer.from(b64Match[1], 'base64').toString('utf8')) as RawSourceMap;
2✔
1773
                }
1774
            } else {
1775
                const mapPath = path.isAbsolute(url) ? url : path.resolve(path.dirname(srcPath), url);
5✔
1776
                if (await fsExtra.pathExists(mapPath)) {
5✔
1777
                    return JSON.parse(await fsExtra.readFile(mapPath, 'utf8')) as RawSourceMap;
4✔
1778
                }
1779
            }
1780
        }
1781

1782
        // no usable sourceMappingURL — try co-located <srcPath>.map
1783
        const colocated = `${srcPath}.map`;
9✔
1784
        if (await fsExtra.pathExists(colocated)) {
9✔
1785
            return JSON.parse(await fsExtra.readFile(colocated, 'utf8')) as RawSourceMap;
2✔
1786
        }
1787
        return undefined;
7✔
1788
    }
1789

1790
    /**
1791
     * Apply an input sourcemap to a generated SourceMapGenerator, chaining mappings so the
1792
     * output traces back through the input map to the original source.
1793
     */
1794
    public async applySourceMap(generator: SourceMapGenerator, inputMap: RawSourceMap, sourceFile: string) {
1795
        await SourceMapConsumer.with(inputMap, null, (consumer) => {
8✔
1796
            generator.applySourceMap(consumer, sourceFile);
8✔
1797
        });
1798
    }
1799

1800
    public isBuiltInType(typeName: string) {
1801
        const typeNameLower = typeName.toLowerCase();
61✔
1802
        if (typeNameLower.startsWith('rosgnode')) {
61✔
1803
            // NOTE: this is unsafe and only used to avoid validation errors in backported v1 type syntax
1804
            return true;
9✔
1805
        }
1806
        return components[typeNameLower] || interfaces[typeNameLower] || events[typeNameLower];
52✔
1807
    }
1808

1809
    /**
1810
     * Get a short name that can be used to reference the project in logs. (typically something like `prj1`, `prj8`, etc...)
1811
     */
1812
    public getProjectLogName(config: { projectNumber: number }) {
1813
        //if we have a project number, use it
1814
        if (config?.projectNumber !== undefined) {
833✔
1815
            return `prj${config.projectNumber}`;
184✔
1816
        }
1817
        //just return empty string so log functions don't crash with undefined project numbers
1818
        return '';
649✔
1819
    }
1820
}
1821

1822
/**
1823
 * 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,
1824
 * we can't use `object.tag` syntax.
1825
 */
1826
export function standardizePath(stringParts, ...expressions: any[]) {
1✔
1827
    let result: string[] = [];
8,971✔
1828
    for (let i = 0; i < stringParts?.length; i++) {
8,971!
1829
        result.push(stringParts[i], expressions[i]);
24,760✔
1830
    }
1831
    return util.standardizePath(
8,971✔
1832
        result.join('')
1833
    );
1834
}
1835

1836
export let util = new Util();
1✔
1837
export default util;
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc