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

rokucommunity / brighterscript / #13308

22 Nov 2024 02:25PM UTC coverage: 86.801%. Remained the same
#13308

push

web-flow
Merge 332332a1f into 2a6afd921

11833 of 14419 branches covered (82.07%)

Branch coverage included in aggregate %.

191 of 205 new or added lines in 26 files covered. (93.17%)

201 existing lines in 18 files now uncovered.

12868 of 14038 relevant lines covered (91.67%)

32022.22 hits per line

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

85.84
/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, standardizePath as rokuDeployStandardizePath } from 'roku-deploy';
1✔
7
import type { DiagnosticRelatedInformation, Diagnostic, Position } from 'vscode-languageserver';
8
import { Location } from 'vscode-languageserver';
1✔
9
import { Range } from 'vscode-languageserver';
1✔
10
import { URI } from 'vscode-uri';
1✔
11
import * as xml2js from 'xml2js';
1✔
12
import type { BsConfig, FinalizedBsConfig } from './BsConfig';
13
import { DiagnosticMessages } from './DiagnosticMessages';
1✔
14
import type { CallableContainer, BsDiagnostic, FileReference, CallableContainerMap, CompilerPluginFactory, CompilerPlugin, ExpressionInfo, TranspileResult, TypeChainProcessResult, GetTypeOptions, ExtraSymbolData } from './interfaces';
15
import { TypeChainEntry } from './interfaces';
1✔
16
import { BooleanType } from './types/BooleanType';
1✔
17
import { DoubleType } from './types/DoubleType';
1✔
18
import { DynamicType } from './types/DynamicType';
1✔
19
import { FloatType } from './types/FloatType';
1✔
20
import { IntegerType } from './types/IntegerType';
1✔
21
import { LongIntegerType } from './types/LongIntegerType';
1✔
22
import { ObjectType } from './types/ObjectType';
1✔
23
import { StringType } from './types/StringType';
1✔
24
import { VoidType } from './types/VoidType';
1✔
25
import { ParseMode } from './parser/Parser';
1✔
26
import type { CallExpression, CallfuncExpression, DottedGetExpression, FunctionParameterExpression, IndexedGetExpression, LiteralExpression, NewExpression, TypeExpression, VariableExpression, XmlAttributeGetExpression } from './parser/Expression';
27
import { LogLevel, createLogger } from './logging';
1✔
28
import { isToken, type Identifier, type Locatable, type Token } from './lexer/Token';
1✔
29
import { TokenKind } from './lexer/TokenKind';
1✔
30
import { isAnyReferenceType, isBinaryExpression, isBooleanType, isBrsFile, isCallExpression, isCallableType, isCallfuncExpression, isClassType, isComponentType, isDottedGetExpression, isDoubleType, isDynamicType, isEnumMemberType, isExpression, isFloatType, isIndexedGetExpression, isInvalidType, isLiteralString, isLongIntegerType, isNamespaceStatement, isNamespaceType, isNewExpression, isNumberType, isReferenceType, isStatement, isStringType, isTypeExpression, isTypedArrayExpression, isTypedFunctionType, isUnionType, isVariableExpression, isXmlAttributeGetExpression, isXmlFile } from './astUtils/reflection';
1✔
31
import { WalkMode } from './astUtils/visitors';
1✔
32
import { SourceNode } from 'source-map';
1✔
33
import * as requireRelative from 'require-relative';
1✔
34
import type { BrsFile } from './files/BrsFile';
35
import type { XmlFile } from './files/XmlFile';
36
import type { AstNode, Expression, Statement } from './parser/AstNode';
37
import { AstNodeKind } from './parser/AstNode';
1✔
38
import type { UnresolvedSymbol } from './AstValidationSegmenter';
39
import type { GetSymbolTypeOptions, SymbolTable } from './SymbolTable';
40
import { SymbolTypeFlag } from './SymbolTypeFlag';
1✔
41
import { createIdentifier, createToken } from './astUtils/creators';
1✔
42
import { MAX_RELATED_INFOS_COUNT } from './diagnosticUtils';
1✔
43
import type { BscType } from './types/BscType';
44
import { unionTypeFactory } from './types/UnionType';
1✔
45
import { ArrayType } from './types/ArrayType';
1✔
46
import { BinaryOperatorReferenceType, TypePropertyReferenceType } from './types/ReferenceType';
1✔
47
import { AssociativeArrayType } from './types/AssociativeArrayType';
1✔
48
import { ComponentType } from './types/ComponentType';
1✔
49
import { FunctionType } from './types/FunctionType';
1✔
50
import type { AssignmentStatement, NamespaceStatement } from './parser/Statement';
51
import type { BscFile } from './files/BscFile';
52
import type { NamespaceType } from './types/NamespaceType';
53

54
export class Util {
1✔
55
    public clearConsole() {
56
        // process.stdout.write('\x1Bc');
57
    }
58

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

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

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

92
    /**
93
     * Determine if this path is a directory
94
     */
95
    public isDirectorySync(dirPath: string | undefined) {
96
        return dirPath !== undefined && fs.existsSync(dirPath) && fs.lstatSync(dirPath).isDirectory();
3✔
97
    }
98

99
    /**
100
     * Given a pkg path of any kind, transform it to a roku-specific pkg path (i.e. "pkg:/some/path.brs")
101
     */
102
    public sanitizePkgPath(pkgPath: string) {
103
        //convert all slashes to forwardslash
104
        pkgPath = pkgPath.replace(/[\/\\]+/g, '/');
49✔
105
        //ensure every path has the leading pkg:/
106
        return 'pkg:/' + pkgPath.replace(/^pkg:\//i, '');
49✔
107
    }
108

109
    /**
110
     * Determine if the given path starts with a protocol
111
     */
112
    public startsWithProtocol(path: string) {
UNCOV
113
        return !!/^[-a-z]+:\//i.exec(path);
×
114
    }
115

116
    /**
117
     * Given a pkg path of any kind, transform it to a roku-specific pkg path (i.e. "pkg:/some/path.brs")
118
     * @deprecated use `sanitizePkgPath instead. Will be removed in v1
119
     */
120
    public getRokuPkgPath(pkgPath: string) {
UNCOV
121
        return this.sanitizePkgPath(pkgPath);
×
122
    }
123

124
    /**
125
     * Given a path to a file/directory, replace all path separators with the current system's version.
126
     */
127
    public pathSepNormalize(filePath: string, separator?: string) {
128
        if (!filePath) {
14!
UNCOV
129
            return filePath;
×
130
        }
131
        separator = separator ? separator : path.sep;
14!
132
        return filePath.replace(/[\\/]+/g, separator);
14✔
133
    }
134

135
    /**
136
     * Find the path to the config file.
137
     * If the config file path doesn't exist
138
     * @param cwd the current working directory where the search for configs should begin
139
     */
140
    public getConfigFilePath(cwd?: string) {
141
        cwd = cwd ?? process.cwd();
69✔
142
        let configPath = path.join(cwd, 'bsconfig.json');
69✔
143
        //find the nearest config file path
144
        for (let i = 0; i < 100; i++) {
69✔
145
            if (this.pathExistsSync(configPath)) {
6,307✔
146
                return configPath;
6✔
147
            } else {
148
                let parentDirPath = path.dirname(path.dirname(configPath));
6,301✔
149
                configPath = path.join(parentDirPath, 'bsconfig.json');
6,301✔
150
            }
151
        }
152
    }
153

154
    public getRangeFromOffsetLength(text: string, offset: number, length: number) {
155
        let lineIndex = 0;
1✔
156
        let colIndex = 0;
1✔
157
        for (let i = 0; i < text.length; i++) {
1✔
158
            if (offset === i) {
1!
UNCOV
159
                break;
×
160
            }
161
            let char = text[i];
1✔
162
            if (char === '\n' || (char === '\r' && text[i + 1] === '\n')) {
1!
UNCOV
163
                lineIndex++;
×
164
                colIndex = 0;
×
165
                i++;
×
166
                continue;
×
167
            } else {
168
                colIndex++;
1✔
169
            }
170
        }
171
        return util.createRange(lineIndex, colIndex, lineIndex, colIndex + length);
1✔
172
    }
173

174
    /**
175
     * Load the contents of a config file.
176
     * If the file extends another config, this will load the base config as well.
177
     * @param configFilePath the relative or absolute path to a brighterscript config json file
178
     * @param parentProjectPaths a list of parent config files. This is used by this method to recursively build the config list
179
     */
180
    public loadConfigFile(configFilePath: string | undefined, parentProjectPaths?: string[], cwd = process.cwd()): BsConfig | undefined {
12✔
181
        if (configFilePath) {
31✔
182
            //if the config file path starts with question mark, then it's optional. return undefined if it doesn't exist
183
            if (configFilePath.startsWith('?')) {
30✔
184
                //remove leading question mark
185
                configFilePath = configFilePath.substring(1);
1✔
186
                if (fsExtra.pathExistsSync(path.resolve(cwd, configFilePath)) === false) {
1!
187
                    return undefined;
1✔
188
                }
189
            }
190
            //keep track of the inheritance chain
191
            parentProjectPaths = parentProjectPaths ? parentProjectPaths : [];
29✔
192
            configFilePath = path.resolve(cwd, configFilePath);
29✔
193
            if (parentProjectPaths?.includes(configFilePath)) {
29!
194
                parentProjectPaths.push(configFilePath);
1✔
195
                parentProjectPaths.reverse();
1✔
196
                throw new Error('Circular dependency detected: "' + parentProjectPaths.join('" => ') + '"');
1✔
197
            }
198
            //load the project file
199
            let projectFileContents = fsExtra.readFileSync(configFilePath).toString();
28✔
200
            let parseErrors = [] as ParseError[];
28✔
201
            let projectConfig = parseJsonc(projectFileContents, parseErrors, {
28✔
202
                allowEmptyContent: true,
203
                allowTrailingComma: true,
204
                disallowComments: false
205
            }) as BsConfig ?? {};
28✔
206
            if (parseErrors.length > 0) {
28✔
207
                let err = parseErrors[0];
1✔
208
                let diagnostic = {
1✔
209
                    ...DiagnosticMessages.bsConfigJsonHasSyntaxErrors(printParseErrorCode(parseErrors[0].error)),
210
                    location: {
211
                        uri: this.pathToUri(configFilePath),
212
                        range: this.getRangeFromOffsetLength(projectFileContents, err.offset, err.length)
213
                    }
214
                } as BsDiagnostic;
215
                throw diagnostic; //eslint-disable-line @typescript-eslint/no-throw-literal
1✔
216
            }
217

218
            let projectFileCwd = path.dirname(configFilePath);
27✔
219

220
            //`plugins` paths should be relative to the current bsconfig
221
            this.resolvePathsRelativeTo(projectConfig, 'plugins', projectFileCwd);
27✔
222

223
            //`require` paths should be relative to cwd
224
            util.resolvePathsRelativeTo(projectConfig, 'require', projectFileCwd);
27✔
225

226
            let result: BsConfig;
227
            //if the project has a base file, load it
228
            if (projectConfig && typeof projectConfig.extends === 'string') {
27✔
229
                let baseProjectConfig = this.loadConfigFile(projectConfig.extends, [...parentProjectPaths, configFilePath], projectFileCwd);
9✔
230
                //extend the base config with the current project settings
231
                result = { ...baseProjectConfig, ...projectConfig };
7✔
232
            } else {
233
                result = projectConfig;
18✔
234
                let ancestors = parentProjectPaths ? parentProjectPaths : [];
18!
235
                ancestors.push(configFilePath);
18✔
236
                (result as any)._ancestors = parentProjectPaths;
18✔
237
            }
238

239
            //make any paths in the config absolute (relative to the CURRENT config file)
240
            if (result.outFile) {
25✔
241
                result.outFile = path.resolve(projectFileCwd, result.outFile);
4✔
242
            }
243
            if (result.rootDir) {
25✔
244
                result.rootDir = path.resolve(projectFileCwd, result.rootDir);
5✔
245
            }
246
            if (result.cwd) {
25✔
247
                result.cwd = path.resolve(projectFileCwd, result.cwd);
1✔
248
            }
249
            if (result.stagingDir) {
25✔
250
                result.stagingDir = path.resolve(projectFileCwd, result.stagingDir);
2✔
251
            }
252
            if (result.sourceRoot && result.resolveSourceRoot) {
25✔
253
                result.sourceRoot = path.resolve(projectFileCwd, result.sourceRoot);
2✔
254
            }
255
            return result;
25✔
256
        }
257
    }
258

259
    /**
260
     * Convert relative paths to absolute paths, relative to the given directory. Also de-dupes the paths. Modifies the array in-place
261
     * @param collection usually a bsconfig.
262
     * @param key a key of the config to read paths from (usually this is `'plugins'` or `'require'`)
263
     * @param relativeDir the path to the folder where the paths should be resolved relative to. This should be an absolute path
264
     */
265
    public resolvePathsRelativeTo(collection: any, key: string, relativeDir: string) {
266
        if (!collection[key]) {
56✔
267
            return;
53✔
268
        }
269
        const result = new Set<string>();
3✔
270
        for (const p of collection[key] as string[] ?? []) {
3!
271
            if (p) {
11✔
272
                result.add(
10✔
273
                    p?.startsWith('.') ? path.resolve(relativeDir, p) : p
40!
274
                );
275
            }
276
        }
277
        collection[key] = [...result];
3✔
278
    }
279

280
    /**
281
     * Do work within the scope of a changed current working directory
282
     * @param targetCwd the cwd where the work should be performed
283
     * @param callback a function to call when the cwd has been changed to `targetCwd`
284
     */
285
    public cwdWork<T>(targetCwd: string | null | undefined, callback: () => T): T {
UNCOV
286
        let originalCwd = process.cwd();
×
287
        if (targetCwd) {
×
288
            process.chdir(targetCwd);
×
289
        }
290

291
        let result: T;
292
        let err;
293

UNCOV
294
        try {
×
295
            result = callback();
×
296
        } catch (e) {
UNCOV
297
            err = e;
×
298
        }
299

UNCOV
300
        if (targetCwd) {
×
301
            process.chdir(originalCwd);
×
302
        }
303

UNCOV
304
        if (err) {
×
305
            throw err;
×
306
        } else {
307
            //justification: `result` is set as long as `err` is not set and vice versa
UNCOV
308
            return result!;
×
309
        }
310
    }
311

312
    /**
313
     * Given a BsConfig object, start with defaults,
314
     * merge with bsconfig.json and the provided options.
315
     * @param config a bsconfig object to use as the baseline for the resulting config
316
     */
317
    public normalizeAndResolveConfig(config: BsConfig | undefined): FinalizedBsConfig {
318
        let result = this.normalizeConfig({});
77✔
319

320
        if (config?.noProject) {
77✔
321
            return result;
1✔
322
        }
323

324
        //if no options were provided, try to find a bsconfig.json file
325
        if (!config || !config.project) {
76✔
326
            result.project = this.getConfigFilePath(config?.cwd);
64✔
327
        } else {
328
            //use the config's project link
329
            result.project = config.project;
12✔
330
        }
331
        if (result.project) {
76✔
332
            let configFile = this.loadConfigFile(result.project, undefined, config?.cwd);
15!
333
            result = Object.assign(result, configFile);
13✔
334
        }
335
        //override the defaults with the specified options
336
        result = Object.assign(result, config);
74✔
337
        return result;
74✔
338
    }
339

340
    /**
341
     * Set defaults for any missing items
342
     * @param config a bsconfig object to use as the baseline for the resulting config
343
     */
344
    public normalizeConfig(config: BsConfig | undefined): FinalizedBsConfig {
345
        config = config ?? {} as BsConfig;
1,955✔
346

347
        const cwd = config.cwd ?? process.cwd();
1,955✔
348
        const rootFolderName = path.basename(cwd);
1,955✔
349
        const retainStagingDir = (config.retainStagingDir ?? config.retainStagingDir) === true ? true : false;
1,955✔
350

351
        let logLevel: LogLevel = LogLevel.log;
1,955✔
352

353
        if (typeof config.logLevel === 'string') {
1,955!
UNCOV
354
            logLevel = LogLevel[(config.logLevel as string).toLowerCase()] ?? LogLevel.log;
×
355
        }
356

357
        let bslibDestinationDir = config.bslibDestinationDir ?? 'source';
1,955✔
358
        if (bslibDestinationDir !== 'source') {
1,955✔
359
            // strip leading and trailing slashes
360
            bslibDestinationDir = bslibDestinationDir.replace(/^(\/*)(.*?)(\/*)$/, '$2');
4✔
361
        }
362

363
        const configWithDefaults: Omit<FinalizedBsConfig, 'rootDir'> = {
1,955✔
364
            cwd: cwd,
365
            deploy: config.deploy === true ? true : false,
1,955!
366
            //use default files array from rokuDeploy
367
            files: config.files ?? [...DefaultFiles],
5,865✔
368
            createPackage: config.createPackage === false ? false : true,
1,955✔
369
            outFile: config.outFile ?? `./out/${rootFolderName}.zip`,
5,865✔
370
            sourceMap: config.sourceMap === true,
371
            username: config.username ?? 'rokudev',
5,865✔
372
            watch: config.watch === true ? true : false,
1,955!
373
            emitFullPaths: config.emitFullPaths === true ? true : false,
1,955!
374
            retainStagingDir: retainStagingDir,
375
            copyToStaging: config.copyToStaging === false ? false : true,
1,955✔
376
            ignoreErrorCodes: config.ignoreErrorCodes ?? [],
5,865✔
377
            diagnosticSeverityOverrides: config.diagnosticSeverityOverrides ?? {},
5,865✔
378
            diagnosticFilters: config.diagnosticFilters ?? [],
5,865✔
379
            plugins: config.plugins ?? [],
5,865✔
380
            pruneEmptyCodeFiles: config.pruneEmptyCodeFiles === true ? true : false,
1,955✔
381
            autoImportComponentScript: config.autoImportComponentScript === true ? true : false,
1,955✔
382
            showDiagnosticsInConsole: config.showDiagnosticsInConsole === false ? false : true,
1,955✔
383
            sourceRoot: config.sourceRoot ? standardizePath(config.sourceRoot) : undefined,
1,955✔
384
            resolveSourceRoot: config.resolveSourceRoot === true ? true : false,
1,955!
385
            allowBrighterScriptInBrightScript: config.allowBrighterScriptInBrightScript === true ? true : false,
1,955!
386
            emitDefinitions: config.emitDefinitions === true ? true : false,
1,955!
387
            removeParameterTypes: config.removeParameterTypes === true ? true : false,
1,955!
388
            logLevel: logLevel,
389
            bslibDestinationDir: bslibDestinationDir,
390
            legacyCallfuncHandling: config.legacyCallfuncHandling === true ? true : false
1,955!
391
        };
392

393
        //mutate `config` in case anyone is holding a reference to the incomplete one
394
        const merged: FinalizedBsConfig = Object.assign(config, configWithDefaults);
1,955✔
395

396
        return merged;
1,955✔
397
    }
398

399
    /**
400
     * Get the root directory from options.
401
     * Falls back to options.cwd.
402
     * Falls back to process.cwd
403
     * @param options a bsconfig object
404
     */
405
    public getRootDir(options: BsConfig) {
406
        if (!options) {
1,839!
UNCOV
407
            throw new Error('Options is required');
×
408
        }
409
        let cwd = options.cwd;
1,839✔
410
        cwd = cwd ? cwd : process.cwd();
1,839!
411
        let rootDir = options.rootDir ? options.rootDir : cwd;
1,839✔
412

413
        rootDir = path.resolve(cwd, rootDir);
1,839✔
414

415
        return rootDir;
1,839✔
416
    }
417

418
    /**
419
     * Given a list of callables as a dictionary indexed by their full name (namespace included, transpiled to underscore-separated.
420
     */
421
    public getCallableContainersByLowerName(callables: CallableContainer[]): CallableContainerMap {
422
        //find duplicate functions
423
        const result = new Map<string, CallableContainer[]>();
1,664✔
424

425
        for (let callableContainer of callables) {
1,664✔
426
            let lowerName = callableContainer.callable.getName(ParseMode.BrightScript).toLowerCase();
131,628✔
427

428
            //create a new array for this name
429
            const list = result.get(lowerName);
131,628✔
430
            if (list) {
131,628✔
431
                list.push(callableContainer);
6,690✔
432
            } else {
433
                result.set(lowerName, [callableContainer]);
124,938✔
434
            }
435
        }
436
        return result;
1,664✔
437
    }
438

439
    /**
440
     * Split a file by newline characters (LF or CRLF)
441
     */
442
    public getLines(text: string) {
UNCOV
443
        return text.split(/\r?\n/);
×
444
    }
445

446
    /**
447
     * Given an absolute path to a source file, and a target path,
448
     * compute the pkg path for the target relative to the source file's location
449
     */
450
    public getPkgPathFromTarget(containingFilePathAbsolute: string, targetPath: string) {
451
        // https://regex101.com/r/w7CG2N/1
452
        const regexp = /^(?:pkg|libpkg):(\/)?/i;
656✔
453
        const [fullScheme, slash] = regexp.exec(targetPath) ?? [];
656✔
454
        //if the target starts with 'pkg:' or 'libpkg:' then it's an absolute path. Return as is
455
        if (slash) {
656✔
456
            targetPath = targetPath.substring(fullScheme.length);
426✔
457
            if (targetPath === '') {
426✔
458
                return null;
2✔
459
            } else {
460
                return path.normalize(targetPath);
424✔
461
            }
462
        }
463
        //if the path is exactly `pkg:` or `libpkg:`
464
        if (targetPath === fullScheme && !slash) {
230✔
465
            return null;
2✔
466
        }
467

468
        //remove the filename
469
        let containingFolder = path.normalize(path.dirname(containingFilePathAbsolute));
228✔
470
        //start with the containing folder, split by slash
471
        let result = containingFolder.split(path.sep);
228✔
472

473
        //split on slash
474
        let targetParts = path.normalize(targetPath).split(path.sep);
228✔
475

476
        for (let part of targetParts) {
228✔
477
            if (part === '' || part === '.') {
232✔
478
                //do nothing, it means current directory
479
                continue;
4✔
480
            }
481
            if (part === '..') {
228✔
482
                //go up one directory
483
                result.pop();
2✔
484
            } else {
485
                result.push(part);
226✔
486
            }
487
        }
488
        return result.join(path.sep);
228✔
489
    }
490

491
    /**
492
     * Compute the relative path from the source file to the target file
493
     * @param pkgSrcPath  - the absolute path to the source, where cwd is the package location
494
     * @param pkgTargetPath  - the absolute path to the target, where cwd is the package location
495
     */
496
    public getRelativePath(pkgSrcPath: string, pkgTargetPath: string) {
497
        pkgSrcPath = path.normalize(pkgSrcPath);
11✔
498
        pkgTargetPath = path.normalize(pkgTargetPath);
11✔
499

500
        //break by path separator
501
        let sourceParts = pkgSrcPath.split(path.sep);
11✔
502
        let targetParts = pkgTargetPath.split(path.sep);
11✔
503

504
        let commonParts = [] as string[];
11✔
505
        //find their common root
506
        for (let i = 0; i < targetParts.length; i++) {
11✔
507
            if (targetParts[i].toLowerCase() === sourceParts[i].toLowerCase()) {
17✔
508
                commonParts.push(targetParts[i]);
6✔
509
            } else {
510
                //we found a non-matching part...so no more commonalities past this point
511
                break;
11✔
512
            }
513
        }
514

515
        //throw out the common parts from both sets
516
        sourceParts.splice(0, commonParts.length);
11✔
517
        targetParts.splice(0, commonParts.length);
11✔
518

519
        //throw out the filename part of source
520
        sourceParts.splice(sourceParts.length - 1, 1);
11✔
521
        //start out by adding updir paths for each remaining source part
522
        let resultParts = sourceParts.map(() => '..');
11✔
523

524
        //now add every target part
525
        resultParts = [...resultParts, ...targetParts];
11✔
526
        return path.join(...resultParts);
11✔
527
    }
528

529
    public getImportPackagePath(srcPath: string, pkgTargetPath: string) {
530
        const srcExt = this.getExtension(srcPath);
6✔
531
        const lowerSrcExt = srcExt.toLowerCase();
6✔
532
        const lowerTargetExt = this.getExtension(pkgTargetPath).toLowerCase();
6✔
533
        if (lowerSrcExt === '.bs' && lowerTargetExt === '.brs') {
6✔
534
            // if source is .bs, use that as the import extenstion
535
            return pkgTargetPath.substring(0, pkgTargetPath.length - lowerTargetExt.length) + srcExt;
3✔
536
        }
537
        return pkgTargetPath;
3✔
538
    }
539

540
    /**
541
     * Walks left in a DottedGetExpression and returns a VariableExpression if found, or undefined if not found
542
     */
543
    public findBeginningVariableExpression(dottedGet: DottedGetExpression): VariableExpression | undefined {
544
        let left: any = dottedGet;
37✔
545
        while (left) {
37✔
546
            if (isVariableExpression(left)) {
69✔
547
                return left;
37✔
548
            } else if (isDottedGetExpression(left)) {
32!
549
                left = left.obj;
32✔
550
            } else {
UNCOV
551
                break;
×
552
            }
553
        }
554
    }
555

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

571
        // Check if `a` is before `b`
572
        if (a.end.line < b.start.line || (a.end.line === b.start.line && a.end.character <= b.start.character)) {
9✔
573
            return false;
1✔
574
        }
575

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

581
        // These ranges must intersect
582
        return true;
7✔
583
    }
584

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

604
        // Check if `b` is before `a`
605
        if (b.end.line < a.start.line || (b.end.line === a.start.line && b.end.character < a.start.character)) {
23✔
606
            return false;
2✔
607
        }
608

609
        // These ranges must intersect
610
        return true;
21✔
611
    }
612

613
    /**
614
     * Test if `position` is in `range`. If the position is at the edges, will return true.
615
     * Adapted from core vscode
616
     */
617
    public rangeContains(range: Range | undefined, position: Position | undefined) {
618
        return this.comparePositionToRange(position, range) === 0;
11,721✔
619
    }
620

621
    public comparePositionToRange(position: Position | undefined, range: Range | undefined) {
622
        //stop if the either range is missng
623
        if (!position || !range) {
13,714✔
624
            return 0;
10✔
625
        }
626

627
        if (this.comparePosition(position, range.start) < 0) {
13,704✔
628
            return -1;
2,130✔
629
        }
630
        if (this.comparePosition(position, range.end) > 0) {
11,574✔
631
            return 1;
8,273✔
632
        }
633
        return 0;
3,301✔
634
    }
635

636
    public comparePosition(a: Position | undefined, b: Position) {
637
        //stop if the either position is missing
638
        if (!a || !b) {
213,581!
UNCOV
639
            return 0;
×
640
        }
641

642
        if (a.line < b.line || (a.line === b.line && a.character < b.character)) {
213,581✔
643
            return -1;
15,077✔
644
        }
645
        if (a.line > b.line || (a.line === b.line && a.character > b.character)) {
198,504✔
646
            return 1;
197,675✔
647
        }
648
        return 0;
829✔
649
    }
650

651
    /**
652
     * Combine all the documentation for a node - uses the AstNode's leadingTrivia property
653
     * @param node the node to get the documentation for
654
     * @param options extra options
655
     * @param options.prettyPrint if true, will format the comment text for markdown
656
     * @param options.commentTokens out Array of tokens that match the comment lines
657
     */
658
    public getNodeDocumentation(node: AstNode, options: { prettyPrint?: boolean; commentTokens?: Token[] } = { prettyPrint: true }) {
942✔
659
        if (!node) {
31,131✔
660
            return '';
1,237✔
661
        }
662
        options = options ?? { prettyPrint: true };
29,894!
663
        options.commentTokens = options.commentTokens ?? [];
29,894✔
664
        const nodeTrivia = node.leadingTrivia ?? [];
29,894✔
665
        const leadingTrivia = isStatement(node)
29,894✔
666
            ? [...(node.annotations?.map(anno => anno.leadingTrivia ?? []).flat() ?? []), ...nodeTrivia]
32!
667
            : nodeTrivia;
668
        const tokens = leadingTrivia?.filter(t => t.kind === TokenKind.Newline || t.kind === TokenKind.Comment);
57,716!
669
        const comments = [] as Token[];
29,894✔
670

671
        let newLinesInRow = 0;
29,894✔
672
        for (let i = tokens.length - 1; i >= 0; i--) {
29,894✔
673
            const token = tokens[i];
26,455✔
674
            //skip whitespace and newline chars
675
            if (token.kind === TokenKind.Comment) {
26,455✔
676
                comments.push(token);
1,000✔
677
                newLinesInRow = 0;
1,000✔
678
            } else if (token.kind === TokenKind.Newline) {
25,455!
679
                //skip these tokens
680
                newLinesInRow++;
25,455✔
681

682
                if (newLinesInRow > 1) {
25,455✔
683
                    // stop processing on empty line.
684
                    break;
2,988✔
685
                }
686
                //any other token means there are no more comments
687
            } else {
UNCOV
688
                break;
×
689
            }
690
        }
691
        const jsDocCommentBlockLine = /(\/\*{2,}|\*{1,}\/)/i;
29,894✔
692
        let usesjsDocCommentBlock = false;
29,894✔
693
        if (comments.length === 0) {
29,894✔
694
            return '';
29,025✔
695
        }
696
        return comments.reverse()
869✔
697
            .map(x => ({ line: x.text.replace(/^('|rem)/i, '').trim(), token: x }))
1,000✔
698
            .filter(({ line }) => {
699
                if (jsDocCommentBlockLine.exec(line)) {
1,000✔
700
                    usesjsDocCommentBlock = true;
30✔
701
                    return false;
30✔
702
                }
703
                return true;
970✔
704
            }).map(({ line, token }) => {
705
                if (usesjsDocCommentBlock) {
970✔
706
                    if (line.startsWith('*')) {
23✔
707
                        //remove jsDoc leading '*'
708
                        line = line.slice(1).trim();
22✔
709
                    }
710
                }
711
                if (options.prettyPrint && line.startsWith('@')) {
970✔
712
                    // Handle jsdoc/brightscriptdoc tags specially
713
                    // make sure they are on their own markdown line, and add italics
714
                    const firstSpaceIndex = line.indexOf(' ');
3✔
715
                    if (firstSpaceIndex === -1) {
3✔
716
                        return `\n_${line}_`;
1✔
717
                    }
718
                    const firstWord = line.substring(0, firstSpaceIndex);
2✔
719
                    return `\n_${firstWord}_ ${line.substring(firstSpaceIndex + 1)}`;
2✔
720
                }
721
                if (options.commentTokens) {
967!
722
                    options.commentTokens.push(token);
967✔
723
                }
724
                return line;
967✔
725
            }).join('\n');
726
    }
727

728
    /**
729
     * Prefixes a component name so it can be used as type in the symbol table, without polluting available symbols
730
     *
731
     * @param sgNodeName the Name of the component
732
     * @returns the node name, prefixed with `roSGNode`
733
     */
734
    public getSgNodeTypeName(sgNodeName: string) {
735
        return 'roSGNode' + sgNodeName;
332,555✔
736
    }
737

738
    /**
739
     * Parse an xml file and get back a javascript object containing its results
740
     */
741
    public parseXml(text: string) {
UNCOV
742
        return new Promise<any>((resolve, reject) => {
×
743
            xml2js.parseString(text, (err, data) => {
×
744
                if (err) {
×
745
                    reject(err);
×
746
                } else {
UNCOV
747
                    resolve(data);
×
748
                }
749
            });
750
        });
751
    }
752

753
    public propertyCount(object: Record<string, unknown>) {
UNCOV
754
        let count = 0;
×
755
        for (let key in object) {
×
756
            if (object.hasOwnProperty(key)) {
×
757
                count++;
×
758
            }
759
        }
UNCOV
760
        return count;
×
761
    }
762

763
    public padLeft(subject: string, totalLength: number, char: string) {
764
        totalLength = totalLength > 1000 ? 1000 : totalLength;
1!
765
        while (subject.length < totalLength) {
1✔
766
            subject = char + subject;
1,000✔
767
        }
768
        return subject;
1✔
769
    }
770

771
    /**
772
     * Does the string appear to be a uri (i.e. does it start with `file:`)
773
     */
774
    public isUriLike(filePath: string) {
775
        return filePath?.indexOf('file:') === 0;// eslint-disable-line @typescript-eslint/prefer-string-starts-ends-with
284,824!
776
    }
777

778
    /**
779
     * Given a file path, convert it to a URI string
780
     */
781
    public pathToUri(filePath: string) {
782
        if (!filePath) {
252,355✔
783
            return filePath;
31,604✔
784
        } else if (this.isUriLike(filePath)) {
220,751✔
785
            return filePath;
199,358✔
786
        } else {
787
            return URI.file(filePath).toString();
21,393✔
788
        }
789
    }
790

791
    /**
792
     * Given a URI, convert that to a regular fs path
793
     */
794
    public uriToPath(uri: string) {
795
        //if this doesn't look like a URI, then assume it's already a path
796
        if (this.isUriLike(uri) === false) {
53,758✔
797
            return uri;
2✔
798
        }
799
        let parsedPath = URI.parse(uri).fsPath;
53,756✔
800

801
        //Uri annoyingly converts all drive letters to lower case...so this will bring back whatever case it came in as
802
        let match = /\/\/\/([a-z]:)/i.exec(uri);
53,756✔
803
        if (match) {
53,756✔
804
            let originalDriveCasing = match[1];
2✔
805
            parsedPath = originalDriveCasing + parsedPath.substring(2);
2✔
806
        }
807
        const normalizedPath = path.normalize(parsedPath);
53,756✔
808
        return normalizedPath;
53,756✔
809
    }
810

811
    /**
812
     * Force the drive letter to lower case
813
     */
814
    public driveLetterToLower(fullPath: string) {
815
        if (fullPath) {
35,859✔
816
            let firstCharCode = fullPath.charCodeAt(0);
35,855✔
817
            if (
35,855✔
818
                //is upper case A-Z
819
                firstCharCode >= 65 && firstCharCode <= 90 &&
52,944✔
820
                //next char is colon
821
                fullPath[1] === ':'
822
            ) {
823
                fullPath = fullPath[0].toLowerCase() + fullPath.substring(1);
3✔
824
            }
825
        }
826
        return fullPath;
35,859✔
827
    }
828

829
    /**
830
     * Replace the first instance of `search` in `subject` with `replacement`
831
     */
832
    public replaceCaseInsensitive(subject: string, search: string, replacement: string) {
833
        let idx = subject.toLowerCase().indexOf(search.toLowerCase());
6,926✔
834
        if (idx > -1) {
6,926✔
835
            let result = subject.substring(0, idx) + replacement + subject.substring(idx + search.length);
2,178✔
836
            return result;
2,178✔
837
        } else {
838
            return subject;
4,748✔
839
        }
840
    }
841

842
    /**
843
     * Determine if two arrays containing primitive values are equal.
844
     * This considers order and compares by equality.
845
     */
846
    public areArraysEqual(arr1: any[], arr2: any[]) {
847
        if (arr1.length !== arr2.length) {
8✔
848
            return false;
3✔
849
        }
850
        for (let i = 0; i < arr1.length; i++) {
5✔
851
            if (arr1[i] !== arr2[i]) {
7✔
852
                return false;
3✔
853
            }
854
        }
855
        return true;
2✔
856
    }
857

858
    /**
859
     * Get the outDir from options, taking into account cwd and absolute outFile paths
860
     */
861
    public getOutDir(options: FinalizedBsConfig) {
862
        options = this.normalizeConfig(options);
2✔
863
        let cwd = path.normalize(options.cwd ? options.cwd : process.cwd());
2!
864
        if (path.isAbsolute(options.outFile)) {
2!
UNCOV
865
            return path.dirname(options.outFile);
×
866
        } else {
867
            return path.normalize(path.join(cwd, path.dirname(options.outFile)));
2✔
868
        }
869
    }
870

871
    /**
872
     * Get paths to all files on disc that match this project's source list
873
     */
874
    public async getFilePaths(options: FinalizedBsConfig) {
875
        let rootDir = this.getRootDir(options);
49✔
876

877
        let files = await rokuDeploy.getFilePaths(options.files, rootDir);
49✔
878
        return files;
49✔
879
    }
880

881
    /**
882
     * Given a path to a brs file, compute the path to a theoretical d.bs file.
883
     * Only `.brs` files can have typedef path, so return undefined for everything else
884
     */
885
    public getTypedefPath(brsSrcPath: string) {
886
        const typedefPath = brsSrcPath
3,531✔
887
            .replace(/\.brs$/i, '.d.bs')
888
            .toLowerCase();
889

890
        if (typedefPath.endsWith('.d.bs')) {
3,531✔
891
            return typedefPath;
2,044✔
892
        } else {
893
            return undefined;
1,487✔
894
        }
895
    }
896

897

898
    /**
899
     * Walks up the chain to find the closest bsconfig.json file
900
     */
901
    public async findClosestConfigFile(currentPath: string): Promise<string | undefined> {
902
        //make the path absolute
903
        currentPath = path.resolve(
6✔
904
            path.normalize(
905
                currentPath
906
            )
907
        );
908

909
        let previousPath: string | undefined;
910
        //using ../ on the root of the drive results in the same file path, so that's how we know we reached the top
911
        while (previousPath !== currentPath) {
6✔
912
            previousPath = currentPath;
28✔
913

914
            let bsPath = path.join(currentPath, 'bsconfig.json');
28✔
915
            let brsPath = path.join(currentPath, 'brsconfig.json');
28✔
916
            if (await this.pathExists(bsPath)) {
28✔
917
                return bsPath;
2✔
918
            } else if (await this.pathExists(brsPath)) {
26✔
919
                return brsPath;
2✔
920
            } else {
921
                //walk upwards one directory
922
                currentPath = path.resolve(path.join(currentPath, '../'));
24✔
923
            }
924
        }
925
        //got to the root path, no config file exists
926
    }
927

928
    /**
929
     * Set a timeout for the specified milliseconds, and resolve the promise once the timeout is finished.
930
     * @param milliseconds the minimum number of milliseconds to sleep for
931
     */
932
    public sleep(milliseconds: number) {
933
        return new Promise((resolve) => {
103✔
934
            //if milliseconds is 0, don't actually timeout (improves unit test throughput)
935
            if (milliseconds === 0) {
103✔
936
                process.nextTick(resolve);
89✔
937
            } else {
938
                setTimeout(resolve, milliseconds);
14✔
939
            }
940
        });
941
    }
942

943
    /**
944
     * Given an array, map and then flatten
945
     * @param array the array to flatMap over
946
     * @param callback a function that is called for every array item
947
     */
948
    public flatMap<T, R>(array: T[], callback: (arg: T) => R[]): R[] {
949
        return Array.prototype.concat.apply([], array.map(callback));
16✔
950
    }
951

952
    /**
953
     * Determines if the position is greater than the range. This means
954
     * the position does not touch the range, and has a position greater than the end
955
     * of the range. A position that touches the last line/char of a range is considered greater
956
     * than the range, because the `range.end` is EXclusive
957
     */
958
    public positionIsGreaterThanRange(position: Position, range: Range) {
959

960
        //if the position is a higher line than the range
961
        if (position.line > range.end.line) {
1,182✔
962
            return true;
1,104✔
963
        } else if (position.line < range.end.line) {
78!
UNCOV
964
            return false;
×
965
        }
966
        //they are on the same line
967

968
        //if the position's char is greater than or equal to the range's
969
        if (position.character >= range.end.character) {
78!
970
            return true;
78✔
971
        } else {
UNCOV
972
            return false;
×
973
        }
974
    }
975

976
    /**
977
     * Get a range back from an object that contains (or is) a range
978
     */
979
    public extractRange(rangeIsh: RangeLike): Range | undefined {
980
        if (!rangeIsh) {
9,846✔
981
            return undefined;
29✔
982
        } else if ('location' in rangeIsh) {
9,817✔
983
            return rangeIsh.location?.range;
6,810✔
984
        } else if ('range' in rangeIsh) {
3,007!
985
            return rangeIsh.range;
3,007✔
UNCOV
986
        } else if (Range.is(rangeIsh)) {
×
987
            return rangeIsh;
×
988
        } else {
UNCOV
989
            return undefined;
×
990
        }
991
    }
992

993

994
    /**
995
     * Get a location object back by extracting location information from other objects that contain location
996
     */
997
    public getRange(startObj: | { range: Range }, endObj: { range: Range }): Range {
UNCOV
998
        if (!startObj?.range || !endObj?.range) {
×
999
            return undefined;
×
1000
        }
UNCOV
1001
        return util.createRangeFromPositions(startObj.range?.start, endObj.range?.end);
×
1002
    }
1003

1004
    /**
1005
     * If the two items both start on the same line
1006
     */
1007
    public sameStartLine(first: { range: Range }, second: { range: Range }) {
UNCOV
1008
        if (first && second && first.range.start.line === second.range.start.line) {
×
1009
            return true;
×
1010
        } else {
UNCOV
1011
            return false;
×
1012
        }
1013
    }
1014

1015
    /**
1016
     * If the two items have lines that touch
1017
     */
1018
    public linesTouch(first: RangeLike, second: RangeLike) {
1019
        const firstRange = this.extractRange(first);
1,532✔
1020
        const secondRange = this.extractRange(second);
1,532✔
1021
        if (firstRange && secondRange && (
1,532✔
1022
            firstRange.start.line === secondRange.start.line ||
1023
            firstRange.start.line === secondRange.end.line ||
1024
            firstRange.end.line === secondRange.start.line ||
1025
            firstRange.end.line === secondRange.end.line
1026
        )) {
1027
            return true;
91✔
1028
        } else {
1029
            return false;
1,441✔
1030
        }
1031
    }
1032

1033
    /**
1034
     * Given text with (or without) dots separating text, get the rightmost word.
1035
     * (i.e. given "A.B.C", returns "C". or "B" returns "B because there's no dot)
1036
     */
1037
    public getTextAfterFinalDot(name: string) {
UNCOV
1038
        if (name) {
×
1039
            let parts = name.split('.');
×
1040
            if (parts.length > 0) {
×
1041
                return parts[parts.length - 1];
×
1042
            }
1043
        }
1044
    }
1045

1046
    /**
1047
     * Find a script import that the current position touches, or undefined if not found
1048
     */
1049
    public getScriptImportAtPosition(scriptImports: FileReference[], position: Position): FileReference | undefined {
1050
        let scriptImport = scriptImports.find((x) => {
118✔
1051
            return x.filePathRange &&
5✔
1052
                x.filePathRange.start.line === position.line &&
1053
                //column between start and end
1054
                position.character >= x.filePathRange.start.character &&
1055
                position.character <= x.filePathRange.end.character;
1056
        });
1057
        return scriptImport;
118✔
1058
    }
1059

1060
    /**
1061
     * Given the class name text, return a namespace-prefixed name.
1062
     * If the name already has a period in it, or the namespaceName was not provided, return the class name as is.
1063
     * If the name does not have a period, and a namespaceName was provided, return the class name prepended by the namespace name.
1064
     * If no namespace is provided, return the `className` unchanged.
1065
     */
1066
    public getFullyQualifiedClassName(className: string, namespaceName?: string) {
1067
        if (className?.includes('.') === false && namespaceName) {
3,872✔
1068
            return `${namespaceName}.${className}`;
217✔
1069
        } else {
1070
            return className;
3,655✔
1071
        }
1072
    }
1073

1074
    public splitIntoLines(string: string) {
1075
        return string.split(/\r?\n/g);
169✔
1076
    }
1077

1078
    public getTextForRange(string: string | string[], range: Range): string {
1079
        let lines: string[];
1080
        if (Array.isArray(string)) {
171✔
1081
            lines = string;
170✔
1082
        } else {
1083
            lines = this.splitIntoLines(string);
1✔
1084
        }
1085

1086
        const start = range.start;
171✔
1087
        const end = range.end;
171✔
1088

1089
        let endCharacter = end.character;
171✔
1090
        // If lines are the same we need to subtract out our new starting position to make it work correctly
1091
        if (start.line === end.line) {
171✔
1092
            endCharacter -= start.character;
159✔
1093
        }
1094

1095
        let rangeLines = [lines[start.line].substring(start.character)];
171✔
1096
        for (let i = start.line + 1; i <= end.line; i++) {
171✔
1097
            rangeLines.push(lines[i]);
12✔
1098
        }
1099
        const lastLine = rangeLines.pop();
171✔
1100
        if (lastLine !== undefined) {
171!
1101
            rangeLines.push(lastLine.substring(0, endCharacter));
171✔
1102
        }
1103
        return rangeLines.join('\n');
171✔
1104
    }
1105

1106
    /**
1107
     * Helper for creating `Location` objects. Prefer using this function because vscode-languageserver's `Location.create()` is significantly slower at scale
1108
     */
1109
    public createLocationFromRange(uri: string, range: Range): Location {
1110
        return {
8,347✔
1111
            uri: util.pathToUri(uri),
1112
            range: range
1113
        };
1114
    }
1115

1116
    /**
1117
     * Helper for creating `Location` objects from a file and range
1118
     */
1119
    public createLocationFromFileRange(file: BscFile, range: Range): Location {
1120
        return this.createLocationFromRange(this.pathToUri(file?.srcPath), range);
379!
1121
    }
1122

1123
    /**
1124
     * Helper for creating `Location` objects by passing each range value in directly. Prefer using this function because vscode-languageserver's `Location.create()` is significantly slower at scale
1125
     */
1126
    public createLocation(startLine: number, startCharacter: number, endLine: number, endCharacter: number, uri?: string): Location {
1127
        return {
234,951✔
1128
            uri: util.pathToUri(uri),
1129
            range: {
1130
                start: {
1131
                    line: startLine,
1132
                    character: startCharacter
1133
                },
1134
                end: {
1135
                    line: endLine,
1136
                    character: endCharacter
1137
                }
1138
            }
1139
        };
1140
    }
1141

1142
    /**
1143
     * Helper for creating `Range` objects. Prefer using this function because vscode-languageserver's `Range.create()` is significantly slower.
1144
     */
1145
    public createRange(startLine: number, startCharacter: number, endLine: number, endCharacter: number): Range {
1146
        return {
8,392✔
1147
            start: {
1148
                line: startLine,
1149
                character: startCharacter
1150
            },
1151
            end: {
1152
                line: endLine,
1153
                character: endCharacter
1154
            }
1155
        };
1156
    }
1157

1158
    /**
1159
     * Create a `Range` from two `Position`s
1160
     */
1161
    public createRangeFromPositions(startPosition: Position, endPosition: Position): Range | undefined {
1162
        startPosition = startPosition ?? endPosition;
171✔
1163
        endPosition = endPosition ?? startPosition;
171✔
1164
        if (!startPosition && !endPosition) {
171!
UNCOV
1165
            return undefined;
×
1166
        }
1167
        return this.createRange(startPosition.line, startPosition.character, endPosition.line, endPosition.character);
171✔
1168
    }
1169

1170
    /**
1171
     * Clone a range
1172
     */
1173
    public cloneLocation(location: Location) {
1174
        if (location) {
14,880✔
1175
            return {
14,715✔
1176
                uri: location.uri,
1177
                range: {
1178
                    start: {
1179
                        line: location.range.start.line,
1180
                        character: location.range.start.character
1181
                    },
1182
                    end: {
1183
                        line: location.range.end.line,
1184
                        character: location.range.end.character
1185
                    }
1186
                }
1187
            };
1188
        } else {
1189
            return location;
165✔
1190
        }
1191
    }
1192

1193
    /**
1194
     * Clone every token
1195
     */
1196
    public cloneToken<T extends Token>(token: T): T {
1197
        if (token) {
2,681✔
1198
            const result = {
2,483✔
1199
                kind: token.kind,
1200
                location: this.cloneLocation(token.location),
1201
                text: token.text,
1202
                isReserved: token.isReserved,
1203
                leadingWhitespace: token.leadingWhitespace,
1204
                leadingTrivia: token.leadingTrivia.map(x => this.cloneToken(x))
1,278✔
1205
            } as Token;
1206
            //handle those tokens that have charCode
1207
            if ('charCode' in token) {
2,483✔
1208
                (result as any).charCode = (token as any).charCode;
3✔
1209
            }
1210
            return result as T;
2,483✔
1211
        } else {
1212
            return token;
198✔
1213
        }
1214
    }
1215

1216
    /**
1217
     *  Gets the bounding range of a bunch of ranges or objects that have ranges
1218
     *  TODO: this does a full iteration of the args. If the args were guaranteed to be in range order, we could optimize this
1219
     */
1220
    public createBoundingLocation(...locatables: Array<{ location?: Location } | Location | { range?: Range } | Range | undefined>): Location | undefined {
1221
        let uri: string | undefined;
1222
        let startPosition: Position | undefined;
1223
        let endPosition: Position | undefined;
1224

1225
        for (let locatable of locatables) {
45,282✔
1226
            let range: Range;
1227
            if (!locatable) {
183,160✔
1228
                continue;
49,042✔
1229
            } else if ('location' in locatable) {
134,118✔
1230
                range = locatable.location?.range;
128,800✔
1231
                if (!uri) {
128,800✔
1232
                    uri = locatable.location?.uri;
52,066✔
1233
                }
1234
            } else if (Location.is(locatable)) {
5,318✔
1235
                range = locatable.range;
5,310✔
1236
                if (!uri) {
5,310✔
1237
                    uri = locatable.uri;
4,723✔
1238
                }
1239
            } else if ('range' in locatable) {
8!
UNCOV
1240
                range = locatable.range;
×
1241
            } else {
1242
                range = locatable as Range;
8✔
1243
            }
1244

1245
            //skip undefined locations or locations without a range
1246
            if (!range) {
134,118✔
1247
                continue;
3,570✔
1248
            }
1249

1250
            if (!startPosition) {
130,548✔
1251
                startPosition = range.start;
44,052✔
1252
            } else if (this.comparePosition(range.start, startPosition) < 0) {
86,496✔
1253
                startPosition = range.start;
855✔
1254
            }
1255
            if (!endPosition) {
130,548✔
1256
                endPosition = range.end;
44,052✔
1257
            } else if (this.comparePosition(range.end, endPosition) > 0) {
86,496✔
1258
                endPosition = range.end;
81,492✔
1259
            }
1260
        }
1261
        if (startPosition && endPosition) {
45,282✔
1262
            return util.createLocation(startPosition.line, startPosition.character, endPosition.line, endPosition.character, uri);
44,052✔
1263
        } else {
1264
            return undefined;
1,230✔
1265
        }
1266
    }
1267

1268
    /**
1269
     *  Gets the bounding range of a bunch of ranges or objects that have ranges
1270
     *  TODO: this does a full iteration of the args. If the args were guaranteed to be in range order, we could optimize this
1271
     */
1272
    public createBoundingRange(...locatables: Array<RangeLike>): Range | undefined {
1273
        return this.createBoundingLocation(...locatables)?.range;
523✔
1274
    }
1275

1276
    /**
1277
     * Gets the bounding range of an object that contains a bunch of tokens
1278
     * @param tokens Object with tokens in it
1279
     * @returns Range containing all the tokens
1280
     */
1281
    public createBoundingLocationFromTokens(tokens: Record<string, { location?: Location }>): Location | undefined {
1282
        let uri: string;
1283
        let startPosition: Position | undefined;
1284
        let endPosition: Position | undefined;
1285
        for (let key in tokens) {
5,116✔
1286
            let token = tokens?.[key];
18,235!
1287
            let locatableRange = token?.location?.range;
18,235✔
1288
            if (!locatableRange) {
18,235✔
1289
                continue;
5,626✔
1290
            }
1291

1292
            if (!startPosition) {
12,609✔
1293
                startPosition = locatableRange.start;
4,959✔
1294
            } else if (this.comparePosition(locatableRange.start, startPosition) < 0) {
7,650✔
1295
                startPosition = locatableRange.start;
2,106✔
1296
            }
1297
            if (!endPosition) {
12,609✔
1298
                endPosition = locatableRange.end;
4,959✔
1299
            } else if (this.comparePosition(locatableRange.end, endPosition) > 0) {
7,650✔
1300
                endPosition = locatableRange.end;
5,428✔
1301
            }
1302
            if (!uri) {
12,609✔
1303
                uri = token.location.uri;
6,116✔
1304
            }
1305
        }
1306
        if (startPosition && endPosition) {
5,116✔
1307
            return this.createLocation(startPosition.line, startPosition.character, endPosition.line, endPosition.character, uri);
4,959✔
1308
        } else {
1309
            return undefined;
157✔
1310
        }
1311
    }
1312

1313
    /**
1314
     * Create a `Position` object. Prefer this over `Position.create` for performance reasons.
1315
     */
1316
    public createPosition(line: number, character: number) {
1317
        return {
372✔
1318
            line: line,
1319
            character: character
1320
        };
1321
    }
1322

1323
    /**
1324
     * Convert a list of tokens into a string, including their leading whitespace
1325
     */
1326
    public tokensToString(tokens: Token[]) {
1327
        let result = '';
1✔
1328
        //skip iterating the final token
1329
        for (let token of tokens) {
1✔
1330
            result += token.leadingWhitespace + token.text;
16✔
1331
        }
1332
        return result;
1✔
1333
    }
1334

1335
    /**
1336
     * Convert a token into a BscType
1337
     */
1338
    public tokenToBscType(token: Token) {
1339
        // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
1340
        switch (token.kind) {
1,840,389✔
1341
            case TokenKind.Boolean:
1,843,782✔
1342
                return new BooleanType(token.text);
158✔
1343
            case TokenKind.True:
1344
            case TokenKind.False:
1345
                return BooleanType.instance;
168✔
1346
            case TokenKind.Double:
1347
                return new DoubleType(token.text);
80✔
1348
            case TokenKind.DoubleLiteral:
1349
                return DoubleType.instance;
8✔
1350
            case TokenKind.Dynamic:
1351
                return new DynamicType(token.text);
113✔
1352
            case TokenKind.Float:
1353
                return new FloatType(token.text);
348✔
1354
            case TokenKind.FloatLiteral:
1355
                return FloatType.instance;
123✔
1356
            case TokenKind.Function:
1357
                return new FunctionType(token.text);
137✔
1358
            case TokenKind.Integer:
1359
                return new IntegerType(token.text);
1,186✔
1360
            case TokenKind.IntegerLiteral:
1361
                return IntegerType.instance;
1,863✔
1362
            case TokenKind.Invalid:
1363
                return DynamicType.instance; // TODO: use InvalidType better new InvalidType(token.text);
88✔
1364
            case TokenKind.LongInteger:
1365
                return new LongIntegerType(token.text);
52✔
1366
            case TokenKind.LongIntegerLiteral:
1367
                return LongIntegerType.instance;
3✔
1368
            case TokenKind.Object:
1369
                return new ObjectType(token.text);
326✔
1370
            case TokenKind.String:
1371
                return new StringType(token.text);
2,018✔
1372
            case TokenKind.StringLiteral:
1373
            case TokenKind.TemplateStringExpressionBegin:
1374
            case TokenKind.TemplateStringExpressionEnd:
1375
            case TokenKind.TemplateStringQuasi:
1376
                return StringType.instance;
1,095✔
1377
            case TokenKind.Void:
1378
                return new VoidType(token.text);
42✔
1379
            case TokenKind.Identifier:
1380
                switch (token.text.toLowerCase()) {
1,832,558✔
1381
                    case 'boolean':
984,549!
1382
                        return new BooleanType(token.text);
229,126✔
1383
                    case 'double':
1384
                        return new DoubleType(token.text);
4✔
1385
                    case 'dynamic':
1386
                        return new DynamicType(token.text);
4✔
1387
                    case 'float':
1388
                        return new FloatType(token.text);
225,548✔
1389
                    case 'function':
UNCOV
1390
                        return new FunctionType(token.text);
×
1391
                    case 'integer':
1392
                        return new IntegerType(token.text);
186,164✔
1393
                    case 'invalid':
UNCOV
1394
                        return DynamicType.instance; // TODO: use InvalidType better new InvalidType(token.text);
×
1395
                    case 'longinteger':
1396
                        return new LongIntegerType(token.text);
4✔
1397
                    case 'object':
1398
                        return new ObjectType(token.text);
4✔
1399
                    case 'string':
1400
                        return new StringType(token.text);
343,691✔
1401
                    case 'void':
1402
                        return new VoidType(token.text);
4✔
1403
                }
1404
        }
1405
    }
1406

1407
    /**
1408
     * Deciphers the correct types for fields based on docs
1409
     * https://developer.roku.com/en-ca/docs/references/scenegraph/xml-elements/interface.md
1410
     * @param typeDescriptor the type descriptor from the docs
1411
     * @returns {BscType} the known type, or dynamic
1412
     */
1413
    public getNodeFieldType(typeDescriptor: string, lookupTable?: SymbolTable): BscType {
1414
        let typeDescriptorLower = typeDescriptor.toLowerCase().trim().replace(/\*/g, '');
1,815,100✔
1415

1416
        if (typeDescriptorLower.startsWith('as ')) {
1,815,100✔
1417
            typeDescriptorLower = typeDescriptorLower.substring(3).trim();
7,160✔
1418
        }
1419
        const nodeFilter = (new RegExp(/^\[?(.* node)/, 'i')).exec(typeDescriptorLower);
1,815,100✔
1420
        if (nodeFilter?.[1]) {
1,815,100✔
1421
            typeDescriptorLower = nodeFilter[1].trim();
37,590✔
1422
        }
1423
        const parensFilter = (new RegExp(/(.*)\(.*\)/, 'gi')).exec(typeDescriptorLower);
1,815,100✔
1424
        if (parensFilter?.[1]) {
1,815,100✔
1425
            typeDescriptorLower = parensFilter[1].trim();
3,580✔
1426
        }
1427

1428
        const bscType = this.tokenToBscType(createToken(TokenKind.Identifier, typeDescriptorLower));
1,815,100✔
1429
        if (bscType) {
1,815,100✔
1430
            return bscType;
984,513✔
1431
        }
1432

1433
        function getRect2dType() {
1434
            const rect2dType = new AssociativeArrayType();
5,374✔
1435
            rect2dType.addMember('height', {}, FloatType.instance, SymbolTypeFlag.runtime);
5,374✔
1436
            rect2dType.addMember('width', {}, FloatType.instance, SymbolTypeFlag.runtime);
5,374✔
1437
            rect2dType.addMember('x', {}, FloatType.instance, SymbolTypeFlag.runtime);
5,374✔
1438
            rect2dType.addMember('y', {}, FloatType.instance, SymbolTypeFlag.runtime);
5,374✔
1439
            return rect2dType;
5,374✔
1440
        }
1441

1442
        function getColorType() {
1443
            return unionTypeFactory([IntegerType.instance, StringType.instance]);
110,984✔
1444
        }
1445

1446
        //check for uniontypes
1447
        const multipleTypes = typeDescriptorLower.split(' or ').map(s => s.trim());
834,167✔
1448
        if (multipleTypes.length > 1) {
830,587✔
1449
            const individualTypes = multipleTypes.map(t => this.getNodeFieldType(t, lookupTable));
7,160✔
1450
            return unionTypeFactory(individualTypes);
3,580✔
1451
        }
1452

1453
        const typeIsArray = typeDescriptorLower.startsWith('array of ') || typeDescriptorLower.startsWith('roarray of ');
827,007✔
1454

1455
        if (typeIsArray) {
827,007✔
1456
            const ofSearch = ' of ';
103,820✔
1457
            const arrayPrefixLength = typeDescriptorLower.indexOf(ofSearch) + ofSearch.length;
103,820✔
1458
            let arrayOfTypeName = typeDescriptorLower.substring(arrayPrefixLength); //cut off beginnin, eg. 'array of' or 'roarray of'
103,820✔
1459
            if (arrayOfTypeName.endsWith('s')) {
103,820✔
1460
                // remove "s" in "floats", etc.
1461
                arrayOfTypeName = arrayOfTypeName.substring(0, arrayOfTypeName.length - 1);
75,180✔
1462
            }
1463
            if (arrayOfTypeName.endsWith('\'')) {
103,820✔
1464
                // remove "'" in "float's", etc.
1465
                arrayOfTypeName = arrayOfTypeName.substring(0, arrayOfTypeName.length - 1);
5,370✔
1466
            }
1467
            if (arrayOfTypeName === 'rectangle') {
103,820✔
1468
                arrayOfTypeName = 'rect2d';
1,790✔
1469
            }
1470
            let arrayType = this.getNodeFieldType(arrayOfTypeName, lookupTable);
103,820✔
1471
            return new ArrayType(arrayType);
103,820✔
1472
        } else if (typeDescriptorLower.startsWith('option ')) {
723,187✔
1473
            const actualTypeName = typeDescriptorLower.substring('option '.length); //cut off beginning 'option '
35,800✔
1474
            return this.getNodeFieldType(actualTypeName, lookupTable);
35,800✔
1475
        } else if (typeDescriptorLower.startsWith('value ')) {
687,387✔
1476
            const actualTypeName = typeDescriptorLower.substring('value '.length); //cut off beginning 'value '
14,320✔
1477
            return this.getNodeFieldType(actualTypeName, lookupTable);
14,320✔
1478
        } else if (typeDescriptorLower === 'n/a') {
673,067✔
1479
            return DynamicType.instance;
3,580✔
1480
        } else if (typeDescriptorLower === 'uri') {
669,487✔
1481
            return StringType.instance;
128,885✔
1482
        } else if (typeDescriptorLower === 'color') {
540,602✔
1483
            return getColorType();
110,983✔
1484
        } else if (typeDescriptorLower === 'vector2d' || typeDescriptorLower === 'floatarray') {
429,619✔
1485
            return new ArrayType(FloatType.instance);
42,961✔
1486
        } else if (typeDescriptorLower === 'vector2darray') {
386,658!
UNCOV
1487
            return new ArrayType(new ArrayType(FloatType.instance));
×
1488
        } else if (typeDescriptorLower === 'intarray') {
386,658✔
1489
            return new ArrayType(IntegerType.instance);
1✔
1490
        } else if (typeDescriptorLower === 'colorarray') {
386,657✔
1491
            return new ArrayType(getColorType());
1✔
1492
        } else if (typeDescriptorLower === 'boolarray') {
386,656!
UNCOV
1493
            return new ArrayType(BooleanType.instance);
×
1494
        } else if (typeDescriptorLower === 'stringarray' || typeDescriptorLower === 'strarray') {
386,656✔
1495
            return new ArrayType(StringType.instance);
1✔
1496
        } else if (typeDescriptorLower === 'int') {
386,655✔
1497
            return IntegerType.instance;
7,160✔
1498
        } else if (typeDescriptorLower === 'time') {
379,495✔
1499
            return DoubleType.instance;
34,011✔
1500
        } else if (typeDescriptorLower === 'str') {
345,484!
UNCOV
1501
            return StringType.instance;
×
1502
        } else if (typeDescriptorLower === 'bool') {
345,484✔
1503
            return BooleanType.instance;
1,790✔
1504
        } else if (typeDescriptorLower === 'array' || typeDescriptorLower === 'roarray') {
343,694✔
1505
            return new ArrayType();
14,321✔
1506
        } else if (typeDescriptorLower === 'assocarray' ||
329,373✔
1507
            typeDescriptorLower === 'associative array' ||
1508
            typeDescriptorLower === 'associativearray' ||
1509
            typeDescriptorLower === 'roassociativearray' ||
1510
            typeDescriptorLower.startsWith('associative array of') ||
1511
            typeDescriptorLower.startsWith('associativearray of') ||
1512
            typeDescriptorLower.startsWith('roassociativearray of')
1513
        ) {
1514
            return new AssociativeArrayType();
62,651✔
1515
        } else if (typeDescriptorLower === 'node') {
266,722✔
1516
            return ComponentType.instance;
16,111✔
1517
        } else if (typeDescriptorLower === 'nodearray') {
250,611✔
1518
            return new ArrayType(ComponentType.instance);
1✔
1519
        } else if (typeDescriptorLower === 'rect2d') {
250,610✔
1520
            return getRect2dType();
5,372✔
1521
        } else if (typeDescriptorLower === 'rect2darray') {
245,238✔
1522
            return new ArrayType(getRect2dType());
2✔
1523
        } else if (typeDescriptorLower === 'font') {
245,236✔
1524
            return this.getNodeFieldType('roSGNodeFont', lookupTable);
39,382✔
1525
        } else if (typeDescriptorLower === 'contentnode') {
205,854✔
1526
            return this.getNodeFieldType('roSGNodeContentNode', lookupTable);
35,800✔
1527
        } else if (typeDescriptorLower.endsWith(' node')) {
170,054✔
1528
            return this.getNodeFieldType('roSgNode' + typeDescriptorLower.substring(0, typeDescriptorLower.length - 5), lookupTable);
35,800✔
1529
        } else if (lookupTable) {
134,254!
1530
            //try doing a lookup
1531
            return lookupTable.getSymbolType(typeDescriptorLower, {
134,254✔
1532
                flags: SymbolTypeFlag.typetime,
1533
                fullName: typeDescriptor,
1534
                tableProvider: () => lookupTable
2✔
1535
            });
1536
        }
1537

UNCOV
1538
        return DynamicType.instance;
×
1539
    }
1540

1541
    /**
1542
     * Return the type of the result of a binary operator
1543
     * Note: compound assignments (eg. +=) internally use a binary expression, so that's why TokenKind.PlusEqual, etc. are here too
1544
     */
1545
    public binaryOperatorResultType(leftType: BscType, operator: Token, rightType: BscType): BscType {
1546
        if ((isAnyReferenceType(leftType) && !leftType.isResolvable()) ||
527✔
1547
            (isAnyReferenceType(rightType) && !rightType.isResolvable())) {
1548
            return new BinaryOperatorReferenceType(leftType, operator, rightType, (lhs, op, rhs) => {
29✔
1549
                return this.binaryOperatorResultType(lhs, op, rhs);
2✔
1550
            });
1551
        }
1552
        if (isEnumMemberType(leftType)) {
498✔
1553
            leftType = leftType.underlyingType;
9✔
1554
        }
1555
        if (isEnumMemberType(rightType)) {
498✔
1556
            rightType = rightType.underlyingType;
8✔
1557
        }
1558
        let hasDouble = isDoubleType(leftType) || isDoubleType(rightType);
498✔
1559
        let hasFloat = isFloatType(leftType) || isFloatType(rightType);
498✔
1560
        let hasLongInteger = isLongIntegerType(leftType) || isLongIntegerType(rightType);
498✔
1561
        let hasInvalid = isInvalidType(leftType) || isInvalidType(rightType);
498✔
1562
        let hasDynamic = isDynamicType(leftType) || isDynamicType(rightType);
498✔
1563
        let bothNumbers = isNumberType(leftType) && isNumberType(rightType);
498✔
1564
        let bothStrings = isStringType(leftType) && isStringType(rightType);
498✔
1565
        let eitherBooleanOrNum = (isNumberType(leftType) || isBooleanType(leftType)) && (isNumberType(rightType) || isBooleanType(rightType));
498✔
1566

1567
        // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
1568
        switch (operator.kind) {
498✔
1569
            // Math operators
1570
            case TokenKind.Plus:
1,501✔
1571
            case TokenKind.PlusEqual:
1572
                if (bothStrings) {
213✔
1573
                    // "string" + "string" is the only binary expression allowed with strings
1574
                    return StringType.instance;
121✔
1575
                }
1576
            // eslint-disable-next-line no-fallthrough
1577
            case TokenKind.Minus:
1578
            case TokenKind.MinusEqual:
1579
            case TokenKind.Star:
1580
            case TokenKind.StarEqual:
1581
            case TokenKind.Mod:
1582
                if (bothNumbers) {
160✔
1583
                    if (hasDouble) {
127✔
1584
                        return DoubleType.instance;
5✔
1585
                    } else if (hasFloat) {
122✔
1586
                        return FloatType.instance;
23✔
1587

1588
                    } else if (hasLongInteger) {
99✔
1589
                        return LongIntegerType.instance;
4✔
1590
                    }
1591
                    return IntegerType.instance;
95✔
1592
                }
1593
                break;
33✔
1594
            case TokenKind.Forwardslash:
1595
            case TokenKind.ForwardslashEqual:
1596
                if (bothNumbers) {
10✔
1597
                    if (hasDouble) {
8✔
1598
                        return DoubleType.instance;
1✔
1599
                    } else if (hasFloat) {
7✔
1600
                        return FloatType.instance;
1✔
1601

1602
                    } else if (hasLongInteger) {
6✔
1603
                        return LongIntegerType.instance;
1✔
1604
                    }
1605
                    return FloatType.instance;
5✔
1606
                }
1607
                break;
2✔
1608
            case TokenKind.Backslash:
1609
            case TokenKind.BackslashEqual:
1610
                if (bothNumbers) {
9✔
1611
                    if (hasLongInteger) {
4!
UNCOV
1612
                        return LongIntegerType.instance;
×
1613
                    }
1614
                    return IntegerType.instance;
4✔
1615
                }
1616
                break;
5✔
1617
            case TokenKind.Caret:
1618
                if (bothNumbers) {
16✔
1619
                    if (hasDouble || hasLongInteger) {
14✔
1620
                        return DoubleType.instance;
2✔
1621
                    } else if (hasFloat) {
12✔
1622
                        return FloatType.instance;
1✔
1623
                    }
1624
                    return IntegerType.instance;
11✔
1625
                }
1626
                break;
2✔
1627
            // Bitshift operators
1628
            case TokenKind.LeftShift:
1629
            case TokenKind.LeftShiftEqual:
1630
            case TokenKind.RightShift:
1631
            case TokenKind.RightShiftEqual:
1632
                if (bothNumbers) {
18✔
1633
                    if (hasLongInteger) {
14✔
1634
                        return LongIntegerType.instance;
2✔
1635
                    }
1636
                    // Bitshifts are allowed with non-integer numerics
1637
                    // but will always truncate to ints
1638
                    return IntegerType.instance;
12✔
1639
                }
1640
                break;
4✔
1641
            // Comparison operators
1642
            // All comparison operators result in boolean
1643
            case TokenKind.Equal:
1644
            case TokenKind.LessGreater:
1645
                // = and <> can accept invalid / dynamic
1646
                if (hasDynamic || hasInvalid || bothStrings || eitherBooleanOrNum) {
83✔
1647
                    return BooleanType.instance;
80✔
1648
                }
1649
                break;
3✔
1650
            case TokenKind.Greater:
1651
            case TokenKind.Less:
1652
            case TokenKind.GreaterEqual:
1653
            case TokenKind.LessEqual:
1654
                if (bothStrings || bothNumbers) {
23✔
1655
                    return BooleanType.instance;
11✔
1656
                }
1657
                break;
12✔
1658
            // Logical or bitwise operators
1659
            case TokenKind.Or:
1660
            case TokenKind.And:
1661
                if (bothNumbers) {
58✔
1662
                    // "and"/"or" represent bitwise operators
1663
                    if (hasLongInteger && !hasDouble && !hasFloat) {
12✔
1664
                        // 2 long ints or long int and int
1665
                        return LongIntegerType.instance;
1✔
1666
                    }
1667
                    return IntegerType.instance;
11✔
1668
                } else if (eitherBooleanOrNum) {
46✔
1669
                    // "and"/"or" represent logical operators
1670
                    return BooleanType.instance;
39✔
1671
                }
1672
                break;
7✔
1673
        }
1674
        return DynamicType.instance;
68✔
1675
    }
1676

1677
    /**
1678
     * Return the type of the result of a binary operator
1679
     */
1680
    public unaryOperatorResultType(operator: Token, exprType: BscType): BscType {
1681
        // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
1682
        switch (operator.kind) {
86✔
1683
            // Math operators
1684
            case TokenKind.Minus:
82✔
1685
                if (isNumberType(exprType)) {
59✔
1686
                    // a negative number will be the same type, eg, double->double, int->int, etc.
1687
                    return exprType;
49✔
1688
                }
1689
                break;
10✔
1690
            case TokenKind.Not:
1691
                if (isBooleanType(exprType)) {
23✔
1692
                    return BooleanType.instance;
10✔
1693
                } else if (isNumberType(exprType)) {
13✔
1694
                    //numbers can be "notted"
1695
                    // by default they go to ints, except longints, which stay that way
1696
                    if (isLongIntegerType(exprType)) {
10✔
1697
                        return LongIntegerType.instance;
1✔
1698
                    }
1699
                    return IntegerType.instance;
9✔
1700
                }
1701
                break;
3✔
1702
        }
1703
        return DynamicType.instance;
17✔
1704
    }
1705

1706
    /**
1707
     * Get the extension for the given file path. Basically the part after the final dot, except for
1708
     * `d.bs` which is treated as single extension
1709
     * @returns the file extension (i.e. ".d.bs", ".bs", ".brs", ".xml", ".jpg", etc...)
1710
     */
1711
    public getExtension(filePath: string) {
1712
        filePath = filePath.toLowerCase();
2,653✔
1713
        if (filePath.endsWith('.d.bs')) {
2,653✔
1714
            return '.d.bs';
33✔
1715
        } else {
1716
            return path.extname(filePath).toLowerCase();
2,620✔
1717
        }
1718
    }
1719

1720
    /**
1721
     * Load and return the list of plugins
1722
     */
1723
    public loadPlugins(cwd: string, pathOrModules: string[], onError?: (pathOrModule: string, err: Error) => void): CompilerPlugin[] {
1724
        const logger = createLogger();
54✔
1725
        return pathOrModules.reduce<CompilerPlugin[]>((acc, pathOrModule) => {
54✔
1726
            if (typeof pathOrModule === 'string') {
6!
1727
                try {
6✔
1728
                    const loaded = requireRelative(pathOrModule, cwd);
6✔
1729
                    const theExport: CompilerPlugin | CompilerPluginFactory = loaded.default ? loaded.default : loaded;
6✔
1730

1731
                    let plugin: CompilerPlugin | undefined;
1732

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

1738
                        // the official plugin format is a factory function that returns a new instance of a plugin.
1739
                    } else if (typeof theExport === 'function') {
4!
1740
                        plugin = theExport();
4✔
1741
                    } else {
1742
                        //this should never happen; somehow an invalid plugin has made it into here
UNCOV
1743
                        throw new Error(`TILT: Encountered an invalid plugin: ${String(plugin)}`);
×
1744
                    }
1745

1746
                    if (!plugin.name) {
6!
UNCOV
1747
                        plugin.name = pathOrModule;
×
1748
                    }
1749
                    acc.push(plugin);
6✔
1750
                } catch (err: any) {
UNCOV
1751
                    if (onError) {
×
1752
                        onError(pathOrModule, err);
×
1753
                    } else {
UNCOV
1754
                        throw err;
×
1755
                    }
1756
                }
1757
            }
1758
            return acc;
6✔
1759
        }, []);
1760
    }
1761

1762
    /**
1763
     * Gathers expressions, variables, and unique names from an expression.
1764
     * This is mostly used for the ternary expression
1765
     */
1766
    public getExpressionInfo(expression: Expression, file: BrsFile): ExpressionInfo {
1767
        const expressions = [expression];
82✔
1768
        const variableExpressions = [] as VariableExpression[];
82✔
1769
        const uniqueVarNames = new Set<string>();
82✔
1770

1771
        function expressionWalker(expression) {
1772
            if (isExpression(expression)) {
124✔
1773
                expressions.push(expression);
120✔
1774
            }
1775
            if (isVariableExpression(expression)) {
124✔
1776
                variableExpressions.push(expression);
30✔
1777
                uniqueVarNames.add(expression.tokens.name.text);
30✔
1778
            }
1779
        }
1780

1781
        // Collect all expressions. Most of these expressions are fairly small so this should be quick!
1782
        // This should only be called during transpile time and only when we actually need it.
1783
        expression?.walk(expressionWalker, {
82✔
1784
            walkMode: WalkMode.visitExpressions
1785
        });
1786

1787
        //handle the expression itself (for situations when expression is a VariableExpression)
1788
        expressionWalker(expression);
82✔
1789

1790
        const scope = file.program.getFirstScopeForFile(file);
82✔
1791
        let filteredVarNames = [...uniqueVarNames];
82✔
1792
        if (scope) {
82!
1793
            filteredVarNames = filteredVarNames.filter((varName: string) => {
82✔
1794
                const varNameLower = varName.toLowerCase();
28✔
1795
                // TODO: include namespaces in this filter
1796
                return !scope.getEnumMap().has(varNameLower) &&
28✔
1797
                    !scope.getConstMap().has(varNameLower);
1798
            });
1799
        }
1800

1801
        return { expressions: expressions, varExpressions: variableExpressions, uniqueVarNames: filteredVarNames };
82✔
1802
    }
1803

1804

1805
    public concatAnnotationLeadingTrivia(stmt: Statement): Token[] {
1806
        return [...(stmt.annotations?.map(anno => anno.leadingTrivia ?? []).flat() ?? []), ...stmt.leadingTrivia];
168!
1807
    }
1808

1809
    /**
1810
     * Create a SourceNode that maps every line to itself. Useful for creating maps for files
1811
     * that haven't changed at all, but we still need the map
1812
     */
1813
    public simpleMap(source: string, src: string) {
1814
        //create a source map from the original source code
1815
        let chunks = [] as (SourceNode | string)[];
7✔
1816
        let lines = src.split(/\r?\n/g);
7✔
1817
        for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
7✔
1818
            let line = lines[lineIndex];
27✔
1819
            chunks.push(
27✔
1820
                lineIndex > 0 ? '\n' : '',
27✔
1821
                new SourceNode(lineIndex + 1, 0, source, line)
1822
            );
1823
        }
1824
        return new SourceNode(null, null, source, chunks);
7✔
1825
    }
1826

1827
    /**
1828
     * Converts a path into a standardized format (drive letter to lower, remove extra slashes, use single slash type, resolve relative parts, etc...)
1829
     */
1830
    public standardizePath(thePath: string) {
1831
        return util.driveLetterToLower(
13,480✔
1832
            rokuDeployStandardizePath(thePath)
1833
        );
1834
    }
1835

1836
    /**
1837
     * Given a Diagnostic or BsDiagnostic, return a deep clone of the diagnostic.
1838
     * @param diagnostic the diagnostic to clone
1839
     * @param relatedInformationFallbackLocation a default location to use for all `relatedInformation` entries that are missing a location
1840
     */
1841
    public toDiagnostic(diagnostic: Diagnostic | BsDiagnostic, relatedInformationFallbackLocation: string): Diagnostic {
1842
        let relatedInformation = diagnostic.relatedInformation ?? [];
15✔
1843
        if (relatedInformation.length > MAX_RELATED_INFOS_COUNT) {
15!
UNCOV
1844
            const relatedInfoLength = relatedInformation.length;
×
1845
            relatedInformation = relatedInformation.slice(0, MAX_RELATED_INFOS_COUNT);
×
1846
            relatedInformation.push({
×
1847
                message: `...and ${relatedInfoLength - MAX_RELATED_INFOS_COUNT} more`,
1848
                location: util.createLocationFromRange('   ', util.createRange(0, 0, 0, 0))
1849
            });
1850
        }
1851

1852
        const range = (diagnostic as BsDiagnostic).location?.range ??
15✔
1853
            (diagnostic as Diagnostic).range;
1854

1855
        let result = {
15✔
1856
            severity: diagnostic.severity,
1857
            range: range,
1858
            message: diagnostic.message,
1859
            relatedInformation: relatedInformation.map(x => {
1860

1861
                //clone related information just in case a plugin added circular ref info here
1862
                const clone = { ...x };
4✔
1863
                if (!clone.location) {
4✔
1864
                    // use the fallback location if available
1865
                    if (relatedInformationFallbackLocation) {
2✔
1866
                        clone.location = util.createLocationFromRange(relatedInformationFallbackLocation, range);
1✔
1867
                    } else {
1868
                        //remove this related information so it doesn't bring crash the language server
1869
                        return undefined;
1✔
1870
                    }
1871
                }
1872
                return clone;
3✔
1873
                //filter out null relatedInformation items
1874
            }).filter((x): x is DiagnosticRelatedInformation => Boolean(x)),
4✔
1875
            code: diagnostic.code,
1876
            source: 'brs'
1877
        } as Diagnostic;
1878
        if (diagnostic?.tags?.length > 0) {
15!
UNCOV
1879
            result.tags = diagnostic.tags;
×
1880
        }
1881
        return result;
15✔
1882
    }
1883

1884
    /**
1885
     * Get the first locatable item found at the specified position
1886
     * @param locatables an array of items that have a `range` property
1887
     * @param position the position that the locatable must contain
1888
     */
1889
    public getFirstLocatableAt(locatables: Locatable[], position: Position) {
UNCOV
1890
        for (let token of locatables) {
×
1891
            if (util.rangeContains(token.location?.range, position)) {
×
1892
                return token;
×
1893
            }
1894
        }
1895
    }
1896

1897
    /**
1898
     * Sort an array of objects that have a Range
1899
     */
1900
    public sortByRange<T extends { range: Range | undefined }>(locatables: T[]) {
1901
        //sort the tokens by range
1902
        return locatables.sort((a, b) => {
29✔
1903
            //handle undefined tokens to prevent crashes
1904
            if (!a?.range) {
252!
1905
                return 1;
1✔
1906
            }
1907
            if (!b?.range) {
251!
UNCOV
1908
                return -1;
×
1909
            }
1910

1911
            //start line
1912
            if (a.range.start.line < b.range.start.line) {
251!
UNCOV
1913
                return -1;
×
1914
            }
1915
            if (a.range.start.line > b.range.start.line) {
251✔
1916
                return 1;
159✔
1917
            }
1918
            //start char
1919
            if (a.range.start.character < b.range.start.character) {
92✔
1920
                return -1;
60✔
1921
            }
1922
            if (a.range.start.character > b.range.start.character) {
32!
1923
                return 1;
32✔
1924
            }
1925
            //end line
UNCOV
1926
            if (a.range.end.line < b.range.end.line) {
×
1927
                return -1;
×
1928
            }
UNCOV
1929
            if (a.range.end.line > b.range.end.line) {
×
1930
                return 1;
×
1931
            }
1932
            //end char
UNCOV
1933
            if (a.range.end.character < b.range.end.character) {
×
1934
                return -1;
×
1935
            } else if (a.range.end.character > b.range.end.character) {
×
1936
                return 1;
×
1937
            }
UNCOV
1938
            return 0;
×
1939
        });
1940
    }
1941

1942
    /**
1943
     * Split the given text and return ranges for each chunk.
1944
     * Only works for single-line strings
1945
     */
1946
    public splitGetRange(separator: string, text: string, range: Range) {
1947
        const chunks = text.split(separator);
3✔
1948
        const result = [] as Array<{ text: string; range: Range }>;
3✔
1949
        let offset = 0;
3✔
1950
        for (let chunk of chunks) {
3✔
1951
            //only keep nonzero chunks
1952
            if (chunk.length > 0) {
8✔
1953
                result.push({
7✔
1954
                    text: chunk,
1955
                    range: this.createRange(
1956
                        range.start.line,
1957
                        range.start.character + offset,
1958
                        range.end.line,
1959
                        range.start.character + offset + chunk.length
1960
                    )
1961
                });
1962
            }
1963
            offset += chunk.length + separator.length;
8✔
1964
        }
1965
        return result;
3✔
1966
    }
1967

1968
    /**
1969
     * Wrap the given code in a markdown code fence (with the language)
1970
     */
1971
    public mdFence(code: string, language = '') {
×
1972
        return '```' + language + '\n' + code + '\n```';
121✔
1973
    }
1974

1975
    /**
1976
     * Gets each part of the dotted get.
1977
     * @param node any ast expression
1978
     * @returns an array of the parts of the dotted get. If not fully a dotted get, then returns undefined
1979
     */
1980
    public getAllDottedGetParts(node: AstNode): Identifier[] | undefined {
1981
        //this is a hot function and has been optimized. Don't rewrite unless necessary
1982
        const parts: Identifier[] = [];
12,374✔
1983
        let nextPart = node;
12,374✔
1984
        loop: while (nextPart) {
12,374✔
1985
            switch (nextPart?.kind) {
18,463!
1986
                case AstNodeKind.AssignmentStatement:
18,463!
1987
                    return [(node as AssignmentStatement).tokens.name];
9✔
1988
                case AstNodeKind.DottedGetExpression:
1989
                    parts.push((nextPart as DottedGetExpression)?.tokens.name);
6,018!
1990
                    nextPart = (nextPart as DottedGetExpression).obj;
6,018✔
1991
                    continue;
6,018✔
1992
                case AstNodeKind.CallExpression:
1993
                    nextPart = (nextPart as CallExpression).callee;
39✔
1994
                    continue;
39✔
1995
                case AstNodeKind.TypeExpression:
UNCOV
1996
                    nextPart = (nextPart as TypeExpression).expression;
×
1997
                    continue;
×
1998
                case AstNodeKind.VariableExpression:
1999
                    parts.push((nextPart as VariableExpression)?.tokens.name);
12,261!
2000
                    break loop;
12,261✔
2001
                case AstNodeKind.LiteralExpression:
2002
                    parts.push((nextPart as LiteralExpression)?.tokens.value as Identifier);
5!
2003
                    break loop;
5✔
2004
                case AstNodeKind.IndexedGetExpression:
2005
                    nextPart = (nextPart as unknown as IndexedGetExpression).obj;
35✔
2006
                    continue;
35✔
2007
                case AstNodeKind.FunctionParameterExpression:
2008
                    return [(nextPart as FunctionParameterExpression).tokens.name];
6✔
2009
                case AstNodeKind.GroupingExpression:
2010
                    parts.push(createIdentifier('()', nextPart.location));
7✔
2011
                    break loop;
7✔
2012
                default:
2013
                    //we found a non-DottedGet expression, so return because this whole operation is invalid.
2014
                    return undefined;
83✔
2015
            }
2016
        }
2017
        return parts.reverse();
12,276✔
2018
    }
2019

2020
    /**
2021
     * Given an expression, return all the DottedGet name parts as a string.
2022
     * Mostly used to convert namespaced item full names to a strings
2023
     */
2024
    public getAllDottedGetPartsAsString(node: Expression | Statement, parseMode = ParseMode.BrighterScript): string {
1,986✔
2025
        //this is a hot function and has been optimized. Don't rewrite unless necessary
2026
        /* eslint-disable no-var */
2027
        var sep = parseMode === ParseMode.BrighterScript ? '.' : '_';
10,844✔
2028
        const parts = this.getAllDottedGetParts(node) ?? [];
10,844✔
2029
        var result = parts[0]?.text;
10,844✔
2030
        for (var i = 1; i < parts.length; i++) {
10,844✔
2031
            result += sep + parts[i].text;
4,997✔
2032
        }
2033
        return result;
10,844✔
2034
        /* eslint-enable no-var */
2035
    }
2036

2037
    public stringJoin(strings: string[], separator: string) {
2038
        // eslint-disable-next-line no-var
UNCOV
2039
        var result = strings[0] ?? '';
×
2040
        // eslint-disable-next-line no-var
UNCOV
2041
        for (var i = 1; i < strings.length; i++) {
×
2042
            result += separator + strings[i];
×
2043
        }
UNCOV
2044
        return result;
×
2045
    }
2046

2047
    /**
2048
     * Break an expression into each part.
2049
     */
2050
    public splitExpression(expression: Expression) {
2051
        const parts: Expression[] = [expression];
10,255✔
2052
        let nextPart = expression;
10,255✔
2053
        while (nextPart) {
10,255✔
2054
            if (isDottedGetExpression(nextPart) || isIndexedGetExpression(nextPart) || isXmlAttributeGetExpression(nextPart)) {
12,875✔
2055
                nextPart = nextPart.obj;
1,020✔
2056

2057
            } else if (isCallExpression(nextPart) || isCallfuncExpression(nextPart)) {
11,855✔
2058
                nextPart = nextPart.callee;
1,600✔
2059

2060
            } else if (isTypeExpression(nextPart)) {
10,255!
UNCOV
2061
                nextPart = nextPart.expression;
×
2062
            } else {
2063
                break;
10,255✔
2064
            }
2065
            parts.unshift(nextPart);
2,620✔
2066
        }
2067
        return parts;
10,255✔
2068
    }
2069

2070
    /**
2071
     * Break an expression into each part, and return any VariableExpression or DottedGet expresisons from left-to-right.
2072
     */
2073
    public getDottedGetPath(expression: Expression): [VariableExpression, ...DottedGetExpression[]] {
UNCOV
2074
        let parts: Expression[] = [];
×
2075
        let nextPart = expression;
×
2076
        loop: while (nextPart) {
×
2077
            switch (nextPart?.kind) {
×
2078
                case AstNodeKind.DottedGetExpression:
×
UNCOV
2079
                    parts.push(nextPart);
×
2080
                    nextPart = (nextPart as DottedGetExpression).obj;
×
2081
                    continue;
×
2082
                case AstNodeKind.IndexedGetExpression:
2083
                case AstNodeKind.XmlAttributeGetExpression:
UNCOV
2084
                    nextPart = (nextPart as IndexedGetExpression | XmlAttributeGetExpression).obj;
×
2085
                    parts = [];
×
2086
                    continue;
×
2087
                case AstNodeKind.CallExpression:
2088
                case AstNodeKind.CallfuncExpression:
UNCOV
2089
                    nextPart = (nextPart as CallExpression | CallfuncExpression).callee;
×
2090
                    parts = [];
×
2091
                    continue;
×
2092
                case AstNodeKind.NewExpression:
UNCOV
2093
                    nextPart = (nextPart as NewExpression).call.callee;
×
2094
                    parts = [];
×
2095
                    continue;
×
2096
                case AstNodeKind.TypeExpression:
UNCOV
2097
                    nextPart = (nextPart as TypeExpression).expression;
×
2098
                    continue;
×
2099
                case AstNodeKind.VariableExpression:
UNCOV
2100
                    parts.push(nextPart);
×
2101
                    break loop;
×
2102
                default:
UNCOV
2103
                    return [] as any;
×
2104
            }
2105
        }
UNCOV
2106
        return parts.reverse() as any;
×
2107
    }
2108

2109
    /**
2110
     * Returns an integer if valid, or undefined. Eliminates checking for NaN
2111
     */
2112
    public parseInt(value: any) {
2113
        const result = parseInt(value);
34✔
2114
        if (!isNaN(result)) {
34✔
2115
            return result;
29✔
2116
        } else {
2117
            return undefined;
5✔
2118
        }
2119
    }
2120

2121
    /**
2122
     * Converts a range to a string in the format 1:2-3:4
2123
     */
2124
    public rangeToString(range: Range) {
2125
        return `${range?.start?.line}:${range?.start?.character}-${range?.end?.line}:${range?.end?.character}`;
1,846✔
2126
    }
2127

2128
    public validateTooDeepFile(file: (BrsFile | XmlFile)) {
2129
        //find any files nested too deep
2130
        let destPath = file?.destPath?.toString();
1,997!
2131
        let rootFolder = destPath?.replace(/^pkg:/, '').split(/[\\\/]/)[0].toLowerCase();
1,997!
2132

2133
        if (isBrsFile(file) && rootFolder !== 'source') {
1,997✔
2134
            return;
302✔
2135
        }
2136

2137
        if (isXmlFile(file) && rootFolder !== 'components') {
1,695!
UNCOV
2138
            return;
×
2139
        }
2140

2141
        let fileDepth = this.getParentDirectoryCount(destPath);
1,695✔
2142
        if (fileDepth >= 8) {
1,695✔
2143
            file.program?.diagnostics.register({
3!
2144
                ...DiagnosticMessages.detectedTooDeepFileSource(fileDepth),
2145
                location: util.createLocationFromFileRange(file, this.createRange(0, 0, 0, Number.MAX_VALUE))
2146
            });
2147
        }
2148
    }
2149

2150
    /**
2151
     * Wraps SourceNode's constructor to be compatible with the TranspileResult type
2152
     */
2153
    public sourceNodeFromTranspileResult(
2154
        line: number | null,
2155
        column: number | null,
2156
        source: string | null,
2157
        chunks?: string | SourceNode | TranspileResult,
2158
        name?: string
2159
    ): SourceNode {
2160
        // we can use a typecast rather than actually transforming the data because SourceNode
2161
        // accepts a more permissive type than its typedef states
2162
        return new SourceNode(line, column, source, chunks as any, name);
7,979✔
2163
    }
2164

2165
    /**
2166
     * Find the index of the last item in the array that matches.
2167
     */
2168
    public findLastIndex<T>(array: T[], matcher: (T) => boolean) {
2169
        for (let i = array.length - 1; i >= 0; i--) {
27✔
2170
            if (matcher(array[i])) {
24✔
2171
                return i;
16✔
2172
            }
2173
        }
2174
    }
2175

2176
    public processTypeChain(typeChain: TypeChainEntry[]): TypeChainProcessResult {
2177
        let fullChainName = '';
1,098✔
2178
        let fullErrorName = '';
1,098✔
2179
        let itemName = '';
1,098✔
2180
        let previousTypeName = '';
1,098✔
2181
        let parentTypeName = '';
1,098✔
2182
        let itemTypeKind = '';
1,098✔
2183
        let parentTypeKind = '';
1,098✔
2184
        let astNode: AstNode;
2185
        let errorLocation: Location;
2186
        let containsDynamic = false;
1,098✔
2187
        let continueResolvingAllItems = true;
1,098✔
2188
        let crossedCallFunc = false;
1,098✔
2189
        for (let i = 0; i < typeChain.length; i++) {
1,098✔
2190
            const chainItem = typeChain[i];
2,156✔
2191
            const dotSep = chainItem.separatorToken?.text ?? '.';
2,156!
2192
            if (i > 0) {
2,156✔
2193
                fullChainName += dotSep;
1,060✔
2194
            }
2195
            fullChainName += chainItem.name;
2,156✔
2196
            if (continueResolvingAllItems) {
2,156✔
2197
                parentTypeName = previousTypeName;
1,966✔
2198
                parentTypeKind = itemTypeKind;
1,966✔
2199
                fullErrorName = previousTypeName ? `${previousTypeName}${dotSep}${chainItem.name}` : chainItem.name;
1,966✔
2200
                itemTypeKind = (chainItem.type as any)?.kind;
1,966✔
2201

2202
                let typeString = chainItem.type?.toString();
1,966✔
2203
                let typeToFindStringFor = chainItem.type;
1,966✔
2204
                while (typeToFindStringFor) {
1,966✔
2205
                    if (isUnionType(chainItem.type)) {
1,962✔
2206
                        typeString = `(${typeToFindStringFor.toString()})`;
7✔
2207
                        break;
7✔
2208
                    } else if (isCallableType(typeToFindStringFor)) {
1,955✔
2209
                        if (isTypedFunctionType(typeToFindStringFor) && i < typeChain.length - 1) {
38✔
2210
                            typeToFindStringFor = typeToFindStringFor.returnType;
10✔
2211
                        } else {
2212
                            typeString = 'function';
28✔
2213
                            break;
28✔
2214
                        }
2215
                        parentTypeName = previousTypeName;
10✔
2216
                    } else if (isNamespaceType(typeToFindStringFor) && parentTypeName) {
1,917✔
2217
                        const chainItemTypeName = typeToFindStringFor.toString();
334✔
2218
                        typeString = parentTypeName + '.' + chainItemTypeName;
334✔
2219
                        if (chainItemTypeName.toLowerCase().startsWith(parentTypeName.toLowerCase())) {
334!
2220
                            // the following namespace already knows...
2221
                            typeString = chainItemTypeName;
334✔
2222
                        }
2223
                        break;
334✔
2224
                    } else {
2225
                        typeString = typeToFindStringFor?.toString();
1,583!
2226
                        break;
1,583✔
2227
                    }
2228
                }
2229

2230
                previousTypeName = typeString ?? '';
1,966✔
2231
                itemName = chainItem.name;
1,966✔
2232
                astNode = chainItem.astNode;
1,966✔
2233
                containsDynamic = containsDynamic || (isDynamicType(chainItem.type) && !isAnyReferenceType(chainItem.type));
1,966✔
2234
                crossedCallFunc = crossedCallFunc || chainItem.data?.isFromCallFunc;
1,966!
2235
                if (!chainItem.isResolved) {
1,966✔
2236
                    errorLocation = chainItem.location;
950✔
2237
                    continueResolvingAllItems = false;
950✔
2238
                }
2239
            }
2240
        }
2241
        return {
1,098✔
2242
            itemName: itemName,
2243
            itemTypeKind: itemTypeKind,
2244
            itemParentTypeName: parentTypeName,
2245
            itemParentTypeKind: parentTypeKind,
2246
            fullNameOfItem: fullErrorName,
2247
            fullChainName: fullChainName,
2248
            location: errorLocation,
2249
            containsDynamic: containsDynamic,
2250
            astNode: astNode,
2251
            crossedCallFunc: crossedCallFunc
2252
        };
2253
    }
2254

2255

2256
    public isInTypeExpression(expression: AstNode): boolean {
2257
        //TODO: this is much faster than node.findAncestor(), but may need to be updated for "complicated" type expressions
2258
        if (isTypeExpression(expression) ||
15,833✔
2259
            isTypeExpression(expression.parent) ||
2260
            isTypedArrayExpression(expression) ||
2261
            isTypedArrayExpression(expression.parent)) {
2262
            return true;
4,685✔
2263
        }
2264
        if (isBinaryExpression(expression.parent)) {
11,148✔
2265
            let currentExpr: AstNode = expression.parent;
2,209✔
2266
            while (isBinaryExpression(currentExpr) && currentExpr.tokens.operator.kind === TokenKind.Or) {
2,209✔
2267
                currentExpr = currentExpr.parent;
118✔
2268
            }
2269
            return isTypeExpression(currentExpr) || isTypedArrayExpression(currentExpr);
2,209✔
2270
        }
2271
        return false;
8,939✔
2272
    }
2273

2274
    public hasAnyRequiredSymbolChanged(requiredSymbols: UnresolvedSymbol[], changedSymbols: Map<SymbolTypeFlag, Set<string>>) {
2275
        if (!requiredSymbols || !changedSymbols) {
1,868!
UNCOV
2276
            return false;
×
2277
        }
2278
        const runTimeChanges = changedSymbols.get(SymbolTypeFlag.runtime);
1,868✔
2279
        const typeTimeChanges = changedSymbols.get(SymbolTypeFlag.typetime);
1,868✔
2280

2281
        for (const symbol of requiredSymbols) {
1,868✔
2282
            if (this.setContainsUnresolvedSymbol(runTimeChanges, symbol) || this.setContainsUnresolvedSymbol(typeTimeChanges, symbol)) {
634✔
2283
                return true;
299✔
2284
            }
2285
        }
2286

2287
        return false;
1,569✔
2288
    }
2289

2290
    public setContainsUnresolvedSymbol(symbolLowerNameSet: Set<string>, symbol: UnresolvedSymbol) {
2291
        if (!symbolLowerNameSet || symbolLowerNameSet.size === 0) {
980✔
2292
            return false;
356✔
2293
        }
2294

2295
        for (const possibleNameLower of symbol.lookups) {
624✔
2296
            if (symbolLowerNameSet.has(possibleNameLower)) {
2,241✔
2297
                return true;
299✔
2298
            }
2299
        }
2300
        return false;
325✔
2301
    }
2302

2303
    public truncate<T>(options: {
2304
        leadingText: string;
2305
        items: T[];
2306
        trailingText?: string;
2307
        maxLength: number;
2308
        itemSeparator?: string;
2309
        partBuilder?: (item: T) => string;
2310
    }): string {
2311
        let leadingText = options.leadingText;
19✔
2312
        let items = options?.items ?? [];
19!
2313
        let trailingText = options?.trailingText ?? '';
19!
2314
        let maxLength = options?.maxLength ?? 160;
19!
2315
        let itemSeparator = options?.itemSeparator ?? ', ';
19!
2316
        let partBuilder = options?.partBuilder ?? ((x) => x.toString());
19!
2317

2318
        let parts = [];
19✔
2319
        let length = leadingText.length + (trailingText?.length ?? 0);
19!
2320

2321
        //calculate the max number of items we could fit in the given space
2322
        for (let i = 0; i < items.length; i++) {
19✔
2323
            let part = partBuilder(items[i]);
91✔
2324
            if (i > 0) {
91✔
2325
                part = itemSeparator + part;
72✔
2326
            }
2327
            parts.push(part);
91✔
2328
            length += part.length;
91✔
2329
            //exit the loop if we've maxed out our length
2330
            if (length >= maxLength) {
91✔
2331
                break;
6✔
2332
            }
2333
        }
2334
        let message: string;
2335
        //we have enough space to include all the parts
2336
        if (parts.length >= items.length) {
19✔
2337
            message = leadingText + parts.join('') + trailingText;
13✔
2338

2339
            //we require truncation
2340
        } else {
2341
            //account for truncation message length including max possible "more" items digits, trailing text length, and the separator between last item and trailing text
2342
            length = leadingText.length + `...and ${items.length} more`.length + itemSeparator.length + (trailingText?.length ?? 0);
6!
2343
            message = leadingText;
6✔
2344
            for (let i = 0; i < parts.length; i++) {
6✔
2345
                //always include at least 2 items. if this part would overflow the max, then skip it and finalize the message
2346
                if (i > 1 && length + parts[i].length > maxLength) {
47✔
2347
                    message += itemSeparator + `...and ${items.length - i} more` + trailingText;
6✔
2348
                    return message;
6✔
2349
                } else {
2350
                    message += parts[i];
41✔
2351
                    length += parts[i].length;
41✔
2352
                }
2353
            }
2354
        }
2355
        return message;
13✔
2356
    }
2357

2358
    public getAstNodeFriendlyName(node: AstNode) {
2359
        return node?.kind.replace(/Statement|Expression/g, '');
235!
2360
    }
2361

2362

2363
    public hasLeadingComments(input: Token | AstNode) {
2364
        const leadingTrivia = isToken(input) ? input?.leadingTrivia : input?.leadingTrivia ?? [];
7,138!
2365
        return !!leadingTrivia.find(t => t.kind === TokenKind.Comment);
14,610✔
2366
    }
2367

2368
    public getLeadingComments(input: Token | AstNode) {
2369
        const leadingTrivia = isToken(input) ? input?.leadingTrivia : input?.leadingTrivia ?? [];
11,338!
2370
        return leadingTrivia.filter(t => t.kind === TokenKind.Comment);
34,829✔
2371
    }
2372

2373
    public isLeadingCommentOnSameLine(line: RangeLike, input: Token | AstNode) {
2374
        const leadingCommentRange = this.getLeadingComments(input)?.[0];
10,513!
2375
        if (leadingCommentRange) {
10,513✔
2376
            return this.linesTouch(line, leadingCommentRange?.location);
1,532!
2377
        }
2378
        return false;
8,981✔
2379
    }
2380

2381
    public isClassUsedAsFunction(potentialClassType: BscType, expression: Expression, options: GetTypeOptions) {
2382
        // eslint-disable-next-line no-bitwise
2383
        if ((options?.flags ?? 0) & SymbolTypeFlag.runtime &&
23,518!
2384
            isClassType(potentialClassType) &&
2385
            !options.isExistenceTest &&
2386
            potentialClassType.name?.toLowerCase() === this.getAllDottedGetPartsAsString(expression)?.toLowerCase() &&
6,300✔
2387
            !expression?.findAncestor(isNewExpression)) {
1,224✔
2388
            return true;
32✔
2389
        }
2390
        return false;
23,486✔
2391
    }
2392

2393
    public getSpecialCaseCallExpressionReturnType(callExpr: CallExpression, options: GetSymbolTypeOptions) {
2394
        if (isVariableExpression(callExpr.callee) && callExpr.callee.tokens.name.text.toLowerCase() === 'createobject') {
657✔
2395
            const componentName = isLiteralString(callExpr.args[0]) ? callExpr.args[0].tokens.value?.text?.replace(/"/g, '') : '';
122!
2396
            const nodeType = componentName.toLowerCase() === 'rosgnode' && isLiteralString(callExpr.args[1]) ? callExpr.args[1].tokens.value?.text?.replace(/"/g, '') : '';
122!
2397
            if (componentName?.toLowerCase().startsWith('ro')) {
122!
2398
                const fullName = componentName + nodeType;
108✔
2399
                const data = {};
108✔
2400
                const symbolTable = callExpr.getSymbolTable();
108✔
2401
                const foundType = symbolTable.getSymbolType(fullName, {
108✔
2402
                    flags: SymbolTypeFlag.typetime,
2403
                    data: data,
2404
                    tableProvider: () => callExpr?.getSymbolTable(),
16!
2405
                    fullName: fullName
2406
                });
2407
                if (foundType) {
108!
2408
                    return foundType;
108✔
2409
                }
2410
            }
2411
        } else if (isDottedGetExpression(callExpr.callee) &&
535✔
2412
            callExpr.callee.tokens?.name?.text?.toLowerCase() === 'callfunc' &&
3,627!
2413
            isLiteralString(callExpr.args?.[0])) {
6!
2414
            return this.getCallFuncType(callExpr, callExpr.args?.[0]?.tokens.value, options);
2!
2415
        } else if (isDottedGetExpression(callExpr.callee) &&
533✔
2416
            callExpr.callee.tokens?.name?.text?.toLowerCase() === 'createchild' &&
3,609!
2417
            isComponentType(callExpr.callee.obj?.getType({ flags: SymbolTypeFlag.runtime })) &&
6!
2418
            isLiteralString(callExpr.args?.[0])) {
6!
2419
            const fullName = `roSGNode${callExpr.args?.[0].tokens?.value?.text?.replace(/"/g, '')}`;
2!
2420
            const data = {};
2✔
2421
            const symbolTable = callExpr.getSymbolTable();
2✔
2422
            return symbolTable.getSymbolType(fullName, {
2✔
2423
                flags: SymbolTypeFlag.typetime,
2424
                data: data,
NEW
2425
                tableProvider: () => callExpr?.getSymbolTable(),
×
2426
                fullName: fullName
2427
            });
2428
        }
2429
    }
2430

2431
    public getCallFuncType(callExpr: CallExpression | CallfuncExpression, methodNameToken: Token | Identifier, options: GetSymbolTypeOptions) {
2432
        let result: BscType;
2433
        let methodName = methodNameToken?.text?.replace(/"/g, ''); // remove quotes if it was the first arg of .callFunc()
57✔
2434

2435
        // a little hacky here with checking options.ignoreCall because callFuncExpression has the method name
2436
        // It's nicer for CallExpression, because it's a call on any expression.
2437
        let calleeType: BscType;
2438
        if (isCallfuncExpression(callExpr)) {
57✔
2439
            calleeType = callExpr.callee.getType({ ...options, flags: SymbolTypeFlag.runtime });
48✔
2440
        } else if (isCallExpression(callExpr) && isDottedGetExpression(callExpr.callee)) {
9!
2441
            calleeType = callExpr.callee.obj.getType({ ...options, flags: SymbolTypeFlag.runtime });
9✔
2442
        }
2443
        if (isComponentType(calleeType) || isReferenceType(calleeType)) {
57✔
2444
            const funcType = (calleeType as ComponentType).getCallFuncType?.(methodName, options);
45!
2445
            if (funcType) {
41✔
2446
                options.typeChain?.push(new TypeChainEntry({
36✔
2447
                    name: methodName,
2448
                    type: funcType,
2449
                    data: options.data,
2450
                    location: methodNameToken.location,
2451
                    separatorToken: createToken(TokenKind.Callfunc),
2452
                    astNode: callExpr
2453
                }));
2454
                if (options.ignoreCall) {
36✔
2455
                    result = funcType;
20✔
2456
                } else if (isCallableType(funcType) && (!isReferenceType(funcType.returnType) || funcType.returnType.isResolvable())) {
16✔
2457
                    result = funcType.returnType;
8✔
2458
                } else if (!isReferenceType(funcType) && (funcType as any)?.returnType?.isResolvable()) {
8!
NEW
2459
                    result = (funcType as any).returnType;
×
2460
                } else {
2461
                    result = new TypePropertyReferenceType(funcType, 'returnType');
8✔
2462
                }
2463
            }
2464
        }
2465
        if (options.data && !options.ignoreCall) {
53✔
2466
            options.data.isFromCallFunc = true;
18✔
2467
        }
2468
        return result;
53✔
2469
    }
2470

2471
    public symbolComesFromSameNode(symbolName: string, definingNode: AstNode, symbolTable: SymbolTable) {
2472
        let nsData: ExtraSymbolData = {};
602✔
2473
        let foundType = symbolTable?.getSymbolType(symbolName, { flags: SymbolTypeFlag.runtime, data: nsData });
602!
2474
        if (foundType && definingNode === nsData?.definingNode) {
602!
2475
            return true;
228✔
2476
        }
2477
        return false;
374✔
2478
    }
2479

2480
    public isCalleeMemberOfNamespace(symbolName: string, nodeWhereUsed: AstNode, namespace?: NamespaceStatement) {
2481
        namespace = namespace ?? nodeWhereUsed.findAncestor<NamespaceStatement>(isNamespaceStatement);
61!
2482

2483
        if (!this.isVariableMemberOfNamespace(symbolName, nodeWhereUsed, namespace)) {
61✔
2484
            return false;
44✔
2485
        }
2486
        const exprType = nodeWhereUsed.getType({ flags: SymbolTypeFlag.runtime });
17✔
2487

2488
        if (isCallableType(exprType) || isClassType(exprType)) {
17!
2489
            return true;
17✔
2490
        }
UNCOV
2491
        return false;
×
2492
    }
2493

2494
    public isVariableMemberOfNamespace(symbolName: string, nodeWhereUsed: AstNode, namespace?: NamespaceStatement) {
2495
        namespace = namespace ?? nodeWhereUsed.findAncestor<NamespaceStatement>(isNamespaceStatement);
1,785✔
2496
        if (!isNamespaceStatement(namespace)) {
1,785✔
2497
            return false;
1,414✔
2498
        }
2499
        const namespaceParts = namespace.getNameParts();
371✔
2500
        let namespaceType: NamespaceType;
2501
        let symbolTable: SymbolTable = namespace.getSymbolTable();
371✔
2502
        for (const part of namespaceParts) {
371✔
2503
            namespaceType = symbolTable.getSymbolType(part.text, { flags: SymbolTypeFlag.runtime }) as NamespaceType;
648✔
2504
            if (namespaceType) {
648✔
2505
                symbolTable = namespaceType.getMemberTable();
647✔
2506
            } else {
2507
                return false;
1✔
2508
            }
2509
        }
2510

2511
        let varData: ExtraSymbolData = {};
370✔
2512
        nodeWhereUsed.getType({ flags: SymbolTypeFlag.runtime, data: varData });
370✔
2513
        const isFromSameNodeInMemberTable = this.symbolComesFromSameNode(symbolName, varData?.definingNode, namespaceType?.getMemberTable());
370!
2514
        return isFromSameNodeInMemberTable;
370✔
2515
    }
2516

2517
    public isVariableShadowingSomething(symbolName: string, nodeWhereUsed: AstNode) {
2518
        let varData: ExtraSymbolData = {};
6,254✔
2519
        let exprType = nodeWhereUsed.getType({ flags: SymbolTypeFlag.runtime, data: varData });
6,254✔
2520
        if (isReferenceType(exprType)) {
6,254✔
2521
            exprType = (exprType as any).getTarget();
5,854✔
2522
        }
2523
        const namespace = nodeWhereUsed?.findAncestor<NamespaceStatement>(isNamespaceStatement);
6,254!
2524

2525
        if (isNamespaceStatement(namespace)) {
6,254✔
2526
            let namespaceHasSymbol = namespace.getSymbolTable().hasSymbol(symbolName, SymbolTypeFlag.runtime);
77✔
2527
            // check if the namespace has a symbol with the same name, but different definiton
2528
            if (namespaceHasSymbol && !this.symbolComesFromSameNode(symbolName, varData.definingNode, namespace.getSymbolTable())) {
77✔
2529
                return true;
14✔
2530
            }
2531
        }
2532
        const bodyTable = nodeWhereUsed.getRoot().getSymbolTable();
6,240✔
2533
        const hasSymbolAtFileLevel = bodyTable.hasSymbol(symbolName, SymbolTypeFlag.runtime);
6,240✔
2534
        if (hasSymbolAtFileLevel && !this.symbolComesFromSameNode(symbolName, varData.definingNode, bodyTable)) {
6,240✔
2535
            return true;
8✔
2536
        }
2537

2538
        return false;
6,232✔
2539
    }
2540

2541
    public chooseTypeFromCodeOrDocComment(codeType: BscType, docType: BscType, options: GetTypeOptions) {
2542
        let returnType: BscType;
2543
        if (options.preferDocType && docType) {
10,737!
UNCOV
2544
            returnType = docType;
×
UNCOV
2545
            if (options.data) {
×
UNCOV
2546
                options.data.isFromDocComment = true;
×
2547
            }
2548
        } else {
2549
            returnType = codeType;
10,737✔
2550
            if (!returnType && docType) {
10,737✔
2551
                returnType = docType;
89✔
2552
                if (options.data) {
89✔
2553
                    options.data.isFromDocComment = true;
24✔
2554
                }
2555
            }
2556
        }
2557
        return returnType;
10,737✔
2558
    }
2559
}
2560

2561
/**
2562
 * 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,
2563
 * we can't use `object.tag` syntax.
2564
 */
2565
export function standardizePath(stringParts, ...expressions: any[]) {
1✔
2566
    let result: string[] = [];
22,377✔
2567
    for (let i = 0; i < stringParts.length; i++) {
22,377✔
2568
        result.push(stringParts[i], expressions[i]);
223,539✔
2569
    }
2570
    return util.driveLetterToLower(
22,377✔
2571
        rokuDeployStandardizePath(
2572
            result.join('')
2573
        )
2574
    );
2575
}
2576

2577
/**
2578
 * An item that can be coerced into a `Range`
2579
 */
2580
export type RangeLike = { location?: Location } | Location | { range?: Range } | Range | undefined;
2581

2582
export let util = new Util();
1✔
2583
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