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

rokucommunity / brighterscript / #10790

18 Sep 2023 06:25PM UTC coverage: 88.054% (-0.03%) from 88.085%
#10790

push

web-flow
add noProject flag to bsc so BSConfig.json not expected (#868)

* initial commit

* update comments

* update code to match suggested comments

* remove developer logging

---------

Co-authored-by: Bronley Plumb <bronley@gmail.com>

5636 of 6870 branches covered (0.0%)

Branch coverage included in aggregate %.

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

8524 of 9211 relevant lines covered (92.54%)

1619.43 hits per line

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

86.86
/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 { Diagnostic, Position, Range, Location } from 'vscode-languageserver';
8
import { URI } from 'vscode-uri';
1✔
9
import * as xml2js from 'xml2js';
1✔
10
import type { BsConfig } from './BsConfig';
11
import { DiagnosticMessages } from './DiagnosticMessages';
1✔
12
import type { CallableContainer, BsDiagnostic, FileReference, CallableContainerMap, CompilerPluginFactory, CompilerPlugin, ExpressionInfo } from './interfaces';
13
import { BooleanType } from './types/BooleanType';
1✔
14
import { DoubleType } from './types/DoubleType';
1✔
15
import { DynamicType } from './types/DynamicType';
1✔
16
import { FloatType } from './types/FloatType';
1✔
17
import { FunctionType } from './types/FunctionType';
1✔
18
import { IntegerType } from './types/IntegerType';
1✔
19
import { InvalidType } from './types/InvalidType';
1✔
20
import { LongIntegerType } from './types/LongIntegerType';
1✔
21
import { ObjectType } from './types/ObjectType';
1✔
22
import { StringType } from './types/StringType';
1✔
23
import { VoidType } from './types/VoidType';
1✔
24
import { ParseMode } from './parser/Parser';
1✔
25
import type { DottedGetExpression, VariableExpression } from './parser/Expression';
26
import { Logger, LogLevel } from './Logger';
1✔
27
import type { Identifier, Locatable, Token } from './lexer/Token';
28
import { TokenKind } from './lexer/TokenKind';
1✔
29
import { isAssignmentStatement, isBrsFile, isCallExpression, isCallfuncExpression, isDottedGetExpression, isExpression, isFunctionParameterExpression, isIndexedGetExpression, isNamespacedVariableNameExpression, isNewExpression, isVariableExpression, isXmlAttributeGetExpression, isXmlFile } from './astUtils/reflection';
1✔
30
import { WalkMode } from './astUtils/visitors';
1✔
31
import { CustomType } from './types/CustomType';
1✔
32
import { SourceNode } from 'source-map';
1✔
33
import type { SGAttribute } from './parser/SGTypes';
34
import * as requireRelative from 'require-relative';
1✔
35
import type { BrsFile } from './files/BrsFile';
36
import type { XmlFile } from './files/XmlFile';
37
import type { Expression, Statement } from './parser/AstNode';
38

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

44
    /**
45
     * Returns the number of parent directories in the filPath
46
     */
47
    public getParentDirectoryCount(filePath: string | undefined) {
48
        if (!filePath) {
674!
49
            return -1;
×
50
        } else {
51
            return filePath.replace(/^pkg:/, '').split(/[\\\/]/).length - 1;
674✔
52
        }
53
    }
54

55
    /**
56
     * Determine if the file exists
57
     */
58
    public async pathExists(filePath: string | undefined) {
59
        if (!filePath) {
114✔
60
            return false;
1✔
61
        } else {
62
            return fsExtra.pathExists(filePath);
113✔
63
        }
64
    }
65

66
    /**
67
     * Determine if the file exists
68
     */
69
    public pathExistsSync(filePath: string | undefined) {
70
        if (!filePath) {
3,006!
71
            return false;
×
72
        } else {
73
            return fsExtra.pathExistsSync(filePath);
3,006✔
74
        }
75
    }
76

77
    /**
78
     * Determine if this path is a directory
79
     */
80
    public isDirectorySync(dirPath: string | undefined) {
81
        return fs.existsSync(dirPath) && fs.lstatSync(dirPath).isDirectory();
3✔
82
    }
83

84
    /**
85
     * Given a pkg path of any kind, transform it to a roku-specific pkg path (i.e. "pkg:/some/path.brs")
86
     */
87
    public sanitizePkgPath(pkgPath: string) {
88
        pkgPath = pkgPath.replace(/\\/g, '/');
×
89
        //if there's no protocol, assume it's supposed to start with `pkg:/`
90
        if (!this.startsWithProtocol(pkgPath)) {
×
91
            pkgPath = 'pkg:/' + pkgPath;
×
92
        }
93
        return pkgPath;
×
94
    }
95

96
    /**
97
     * Determine if the given path starts with a protocol
98
     */
99
    public startsWithProtocol(path: string) {
100
        return !!/^[-a-z]+:\//i.exec(path);
×
101
    }
102

103
    /**
104
     * Given a pkg path of any kind, transform it to a roku-specific pkg path (i.e. "pkg:/some/path.brs")
105
     */
106
    public getRokuPkgPath(pkgPath: string) {
107
        pkgPath = pkgPath.replace(/\\/g, '/');
33✔
108
        return 'pkg:/' + pkgPath;
33✔
109
    }
110

111
    /**
112
     * Given a path to a file/directory, replace all path separators with the current system's version.
113
     */
114
    public pathSepNormalize(filePath: string, separator?: string) {
115
        if (!filePath) {
211!
116
            return filePath;
×
117
        }
118
        separator = separator ? separator : path.sep;
211✔
119
        return filePath.replace(/[\\/]+/g, separator);
211✔
120
    }
121

122
    /**
123
     * Find the path to the config file.
124
     * If the config file path doesn't exist
125
     * @param cwd the current working directory where the search for configs should begin
126
     */
127
    public getConfigFilePath(cwd?: string) {
128
        cwd = cwd ?? process.cwd();
35✔
129
        let configPath = path.join(cwd, 'bsconfig.json');
35✔
130
        //find the nearest config file path
131
        for (let i = 0; i < 100; i++) {
35✔
132
            if (this.pathExistsSync(configPath)) {
3,006✔
133
                return configPath;
5✔
134
            } else {
135
                let parentDirPath = path.dirname(path.dirname(configPath));
3,001✔
136
                configPath = path.join(parentDirPath, 'bsconfig.json');
3,001✔
137
            }
138
        }
139
    }
140

141
    public getRangeFromOffsetLength(text: string, offset: number, length: number) {
142
        let lineIndex = 0;
1✔
143
        let colIndex = 0;
1✔
144
        for (let i = 0; i < text.length; i++) {
1✔
145
            if (offset === i) {
1!
146
                break;
×
147
            }
148
            let char = text[i];
1✔
149
            if (char === '\n' || (char === '\r' && text[i + 1] === '\n')) {
1!
150
                lineIndex++;
×
151
                colIndex = 0;
×
152
                i++;
×
153
                continue;
×
154
            } else {
155
                colIndex++;
1✔
156
            }
157
        }
158
        return util.createRange(lineIndex, colIndex, lineIndex, colIndex + length);
1✔
159
    }
160

161
    /**
162
     * Load the contents of a config file.
163
     * If the file extends another config, this will load the base config as well.
164
     * @param configFilePath the relative or absolute path to a brighterscript config json file
165
     * @param parentProjectPaths a list of parent config files. This is used by this method to recursively build the config list
166
     */
167
    public loadConfigFile(configFilePath: string, parentProjectPaths?: string[], cwd = process.cwd()) {
9✔
168
        if (configFilePath) {
25✔
169
            //if the config file path starts with question mark, then it's optional. return undefined if it doesn't exist
170
            if (configFilePath.startsWith('?')) {
24✔
171
                //remove leading question mark
172
                configFilePath = configFilePath.substring(1);
1✔
173
                if (fsExtra.pathExistsSync(path.resolve(cwd, configFilePath)) === false) {
1!
174
                    return undefined;
1✔
175
                }
176
            }
177
            //keep track of the inheritance chain
178
            parentProjectPaths = parentProjectPaths ? parentProjectPaths : [];
23✔
179
            configFilePath = path.resolve(cwd, configFilePath);
23✔
180
            if (parentProjectPaths?.includes(configFilePath)) {
23!
181
                parentProjectPaths.push(configFilePath);
1✔
182
                parentProjectPaths.reverse();
1✔
183
                throw new Error('Circular dependency detected: "' + parentProjectPaths.join('" => ') + '"');
1✔
184
            }
185
            //load the project file
186
            let projectFileContents = fsExtra.readFileSync(configFilePath).toString();
22✔
187
            let parseErrors = [] as ParseError[];
22✔
188
            let projectConfig = parseJsonc(projectFileContents, parseErrors, {
22✔
189
                allowEmptyContent: true,
190
                allowTrailingComma: true,
191
                disallowComments: false
192
            }) as BsConfig ?? {};
22✔
193
            if (parseErrors.length > 0) {
22✔
194
                let err = parseErrors[0];
1✔
195
                let diagnostic = {
1✔
196
                    ...DiagnosticMessages.bsConfigJsonHasSyntaxErrors(printParseErrorCode(parseErrors[0].error)),
197
                    file: {
198
                        srcPath: configFilePath
199
                    },
200
                    range: this.getRangeFromOffsetLength(projectFileContents, err.offset, err.length)
201
                } as BsDiagnostic;
202
                throw diagnostic; //eslint-disable-line @typescript-eslint/no-throw-literal
1✔
203
            }
204

205
            let projectFileCwd = path.dirname(configFilePath);
21✔
206

207
            //`plugins` paths should be relative to the current bsconfig
208
            this.resolvePathsRelativeTo(projectConfig, 'plugins', projectFileCwd);
21✔
209

210
            //`require` paths should be relative to cwd
211
            util.resolvePathsRelativeTo(projectConfig, 'require', projectFileCwd);
21✔
212

213
            let result: BsConfig;
214
            //if the project has a base file, load it
215
            if (projectConfig && typeof projectConfig.extends === 'string') {
21✔
216
                let baseProjectConfig = this.loadConfigFile(projectConfig.extends, [...parentProjectPaths, configFilePath], projectFileCwd);
7✔
217
                //extend the base config with the current project settings
218
                result = { ...baseProjectConfig, ...projectConfig };
5✔
219
            } else {
220
                result = projectConfig;
14✔
221
                let ancestors = parentProjectPaths ? parentProjectPaths : [];
14!
222
                ancestors.push(configFilePath);
14✔
223
                (result as any)._ancestors = parentProjectPaths;
14✔
224
            }
225

226
            //make any paths in the config absolute (relative to the CURRENT config file)
227
            if (result.outFile) {
19✔
228
                result.outFile = path.resolve(projectFileCwd, result.outFile);
3✔
229
            }
230
            if (result.rootDir) {
19✔
231
                result.rootDir = path.resolve(projectFileCwd, result.rootDir);
3✔
232
            }
233
            if (result.cwd) {
19!
234
                result.cwd = path.resolve(projectFileCwd, result.cwd);
×
235
            }
236
            return result;
19✔
237
        }
238
    }
239

240
    /**
241
     * Convert relative paths to absolute paths, relative to the given directory. Also de-dupes the paths. Modifies the array in-place
242
     * @param collection usually a bsconfig.
243
     * @param key a key of the config to read paths from (usually this is `'plugins'` or `'require'`)
244
     * @param relativeDir the path to the folder where the paths should be resolved relative to. This should be an absolute path
245
     */
246
    public resolvePathsRelativeTo(collection: any, key: string, relativeDir: string) {
247
        if (!collection[key]) {
44✔
248
            return;
41✔
249
        }
250
        const result = new Set<string>();
3✔
251
        for (const p of collection[key] as string[] ?? []) {
3!
252
            if (p) {
11✔
253
                result.add(
10✔
254
                    p?.startsWith('.') ? path.resolve(relativeDir, p) : p
40!
255
                );
256
            }
257
        }
258
        collection[key] = [...result];
3✔
259
    }
260

261
    /**
262
     * Do work within the scope of a changed current working directory
263
     * @param targetCwd the cwd where the work should be performed
264
     * @param callback a function to call when the cwd has been changed to `targetCwd`
265
     */
266
    public cwdWork<T>(targetCwd: string | null | undefined, callback: () => T) {
267
        let originalCwd = process.cwd();
4✔
268
        if (targetCwd) {
4!
269
            process.chdir(targetCwd);
4✔
270
        }
271

272
        let result: T;
273
        let err;
274

275
        try {
4✔
276
            result = callback();
4✔
277
        } catch (e) {
278
            err = e;
×
279
        }
280

281
        if (targetCwd) {
4!
282
            process.chdir(originalCwd);
4✔
283
        }
284

285
        if (err) {
4!
286
            throw err;
×
287
        } else {
288
            return result;
4✔
289
        }
290
    }
291

292
    /**
293
     * Given a BsConfig object, start with defaults,
294
     * merge with bsconfig.json and the provided options.
295
     * @param config a bsconfig object to use as the baseline for the resulting config
296
     */
297
    public normalizeAndResolveConfig(config: BsConfig) {
298
        let result = this.normalizeConfig({});
61✔
299

300
        if (config?.noProject) {
61!
301
            return result;
×
302
        }
303

304
        result.project = null;
61✔
305
        if (config?.project) {
61!
306
            result.project = config?.project;
12!
307
        } else {
308
            if (config?.cwd) {
49!
309
                result.project = this.getConfigFilePath(config?.cwd);
30!
310
            }
311
        }
312
        if (result.project) {
61✔
313
            let configFile = this.loadConfigFile(result.project, null, config?.cwd);
14!
314
            result = Object.assign(result, configFile);
12✔
315
        }
316

317
        //override the defaults with the specified options
318
        result = Object.assign(result, config);
59✔
319

320
        return result;
59✔
321
    }
322

323
    /**
324
     * Set defaults for any missing items
325
     * @param config a bsconfig object to use as the baseline for the resulting config
326
     */
327
    public normalizeConfig(config: BsConfig) {
328
        config = config || {} as BsConfig;
1,097✔
329
        config.cwd = config.cwd ?? process.cwd();
1,097✔
330
        config.deploy = config.deploy === true ? true : false;
1,097!
331
        //use default files array from rokuDeploy
332
        config.files = config.files ?? [...DefaultFiles];
1,097✔
333
        config.createPackage = config.createPackage === false ? false : true;
1,097✔
334
        let rootFolderName = path.basename(config.cwd);
1,097✔
335
        config.outFile = config.outFile ?? `./out/${rootFolderName}.zip`;
1,097✔
336
        config.sourceMap = config.sourceMap === true;
1,097✔
337
        config.username = config.username ?? 'rokudev';
1,097✔
338
        config.watch = config.watch === true ? true : false;
1,097!
339
        config.emitFullPaths = config.emitFullPaths === true ? true : false;
1,097!
340
        config.retainStagingDir = (config.retainStagingDir ?? config.retainStagingFolder) === true ? true : false;
1,097✔
341
        config.retainStagingFolder = config.retainStagingDir;
1,097✔
342
        config.copyToStaging = config.copyToStaging === false ? false : true;
1,097✔
343
        config.ignoreErrorCodes = config.ignoreErrorCodes ?? [];
1,097✔
344
        config.diagnosticSeverityOverrides = config.diagnosticSeverityOverrides ?? {};
1,097✔
345
        config.diagnosticFilters = config.diagnosticFilters ?? [];
1,097✔
346
        config.plugins = config.plugins ?? [];
1,097✔
347
        config.autoImportComponentScript = config.autoImportComponentScript === true ? true : false;
1,097✔
348
        config.showDiagnosticsInConsole = config.showDiagnosticsInConsole === false ? false : true;
1,097✔
349
        config.sourceRoot = config.sourceRoot ? standardizePath(config.sourceRoot) : undefined;
1,097✔
350
        config.allowBrighterScriptInBrightScript = config.allowBrighterScriptInBrightScript === true ? true : false;
1,097!
351
        config.emitDefinitions = config.emitDefinitions === true ? true : false;
1,097!
352
        config.removeParameterTypes = config.removeParameterTypes === true ? true : false;
1,097!
353
        if (typeof config.logLevel === 'string') {
1,097!
354
            config.logLevel = LogLevel[(config.logLevel as string).toLowerCase()];
×
355
        }
356
        config.logLevel = config.logLevel ?? LogLevel.log;
1,097✔
357
        config.bslibDestinationDir = config.bslibDestinationDir ?? 'source';
1,097✔
358
        if (config.bslibDestinationDir !== 'source') {
1,097✔
359
            // strip leading and trailing slashes
360
            config.bslibDestinationDir = config.bslibDestinationDir.replace(/^(\/*)(.*?)(\/*)$/, '$2');
4✔
361
        }
362
        return config;
1,097✔
363
    }
364

365
    /**
366
     * Get the root directory from options.
367
     * Falls back to options.cwd.
368
     * Falls back to process.cwd
369
     * @param options a bsconfig object
370
     */
371
    public getRootDir(options: BsConfig) {
372
        if (!options) {
1,061!
373
            throw new Error('Options is required');
×
374
        }
375
        let cwd = options.cwd;
1,061✔
376
        cwd = cwd ? cwd : process.cwd();
1,061!
377
        let rootDir = options.rootDir ? options.rootDir : cwd;
1,061✔
378

379
        rootDir = path.resolve(cwd, rootDir);
1,061✔
380

381
        return rootDir;
1,061✔
382
    }
383

384
    /**
385
     * Given a list of callables as a dictionary indexed by their full name (namespace included, transpiled to underscore-separated.
386
     */
387
    public getCallableContainersByLowerName(callables: CallableContainer[]): CallableContainerMap {
388
        //find duplicate functions
389
        const result = new Map<string, CallableContainer[]>();
1,689✔
390

391
        for (let callableContainer of callables) {
1,689✔
392
            let lowerName = callableContainer.callable.getName(ParseMode.BrightScript).toLowerCase();
130,705✔
393

394
            //create a new array for this name
395
            const list = result.get(lowerName);
130,705✔
396
            if (list) {
130,705✔
397
                list.push(callableContainer);
6,786✔
398
            } else {
399
                result.set(lowerName, [callableContainer]);
123,919✔
400
            }
401
        }
402
        return result;
1,689✔
403
    }
404

405
    /**
406
     * Split a file by newline characters (LF or CRLF)
407
     */
408
    public getLines(text: string) {
409
        return text.split(/\r?\n/);
×
410
    }
411

412
    /**
413
     * Given an absolute path to a source file, and a target path,
414
     * compute the pkg path for the target relative to the source file's location
415
     */
416
    public getPkgPathFromTarget(containingFilePathAbsolute: string, targetPath: string) {
417
        //if the target starts with 'pkg:', it's an absolute path. Return as is
418
        if (targetPath.startsWith('pkg:/')) {
185✔
419
            targetPath = targetPath.substring(5);
68✔
420
            if (targetPath === '') {
68✔
421
                return null;
2✔
422
            } else {
423
                return path.normalize(targetPath);
66✔
424
            }
425
        }
426
        if (targetPath === 'pkg:') {
117✔
427
            return null;
2✔
428
        }
429

430
        //remove the filename
431
        let containingFolder = path.normalize(path.dirname(containingFilePathAbsolute));
115✔
432
        //start with the containing folder, split by slash
433
        let result = containingFolder.split(path.sep);
115✔
434

435
        //split on slash
436
        let targetParts = path.normalize(targetPath).split(path.sep);
115✔
437

438
        for (let part of targetParts) {
115✔
439
            if (part === '' || part === '.') {
119✔
440
                //do nothing, it means current directory
441
                continue;
4✔
442
            }
443
            if (part === '..') {
115✔
444
                //go up one directory
445
                result.pop();
2✔
446
            } else {
447
                result.push(part);
113✔
448
            }
449
        }
450
        return result.join(path.sep);
115✔
451
    }
452

453
    /**
454
     * Compute the relative path from the source file to the target file
455
     * @param pkgSrcPath  - the absolute path to the source, where cwd is the package location
456
     * @param pkgTargetPath  - the absolute path to the target, where cwd is the package location
457
     */
458
    public getRelativePath(pkgSrcPath: string, pkgTargetPath: string) {
459
        pkgSrcPath = path.normalize(pkgSrcPath);
8✔
460
        pkgTargetPath = path.normalize(pkgTargetPath);
8✔
461

462
        //break by path separator
463
        let sourceParts = pkgSrcPath.split(path.sep);
8✔
464
        let targetParts = pkgTargetPath.split(path.sep);
8✔
465

466
        let commonParts = [] as string[];
8✔
467
        //find their common root
468
        for (let i = 0; i < targetParts.length; i++) {
8✔
469
            if (targetParts[i].toLowerCase() === sourceParts[i].toLowerCase()) {
14✔
470
                commonParts.push(targetParts[i]);
6✔
471
            } else {
472
                //we found a non-matching part...so no more commonalities past this point
473
                break;
8✔
474
            }
475
        }
476

477
        //throw out the common parts from both sets
478
        sourceParts.splice(0, commonParts.length);
8✔
479
        targetParts.splice(0, commonParts.length);
8✔
480

481
        //throw out the filename part of source
482
        sourceParts.splice(sourceParts.length - 1, 1);
8✔
483
        //start out by adding updir paths for each remaining source part
484
        let resultParts = sourceParts.map(() => '..');
8✔
485

486
        //now add every target part
487
        resultParts = [...resultParts, ...targetParts];
8✔
488
        return path.join(...resultParts);
8✔
489
    }
490

491
    /**
492
     * Walks left in a DottedGetExpression and returns a VariableExpression if found, or undefined if not found
493
     */
494
    public findBeginningVariableExpression(dottedGet: DottedGetExpression): VariableExpression | undefined {
495
        let left: any = dottedGet;
18✔
496
        while (left) {
18✔
497
            if (isVariableExpression(left)) {
30✔
498
                return left;
18✔
499
            } else if (isDottedGetExpression(left)) {
12!
500
                left = left.obj;
12✔
501
            } else {
502
                break;
×
503
            }
504
        }
505
    }
506

507
    /**
508
     * Do `a` and `b` overlap by at least one character. This returns false if they are at the edges. Here's some examples:
509
     * ```
510
     * | true | true | true | true | true | false | false | false | false |
511
     * |------|------|------|------|------|-------|-------|-------|-------|
512
     * | aa   |  aaa |  aaa | aaa  |  a   |  aa   |    aa | a     |     a |
513
     * |  bbb | bb   |  bbb |  b   | bbb  |    bb |  bb   |     b | a     |
514
     * ```
515
     */
516
    public rangesIntersect(a: Range, b: Range) {
517
        //stop if the either range is misisng
518
        if (!a || !b) {
11✔
519
            return false;
2✔
520
        }
521

522
        // Check if `a` is before `b`
523
        if (a.end.line < b.start.line || (a.end.line === b.start.line && a.end.character <= b.start.character)) {
9✔
524
            return false;
1✔
525
        }
526

527
        // Check if `b` is before `a`
528
        if (b.end.line < a.start.line || (b.end.line === a.start.line && b.end.character <= a.start.character)) {
8✔
529
            return false;
1✔
530
        }
531

532
        // These ranges must intersect
533
        return true;
7✔
534
    }
535

536
    /**
537
     * Do `a` and `b` overlap by at least one character or touch at the edges
538
     * ```
539
     * | true | true | true | true | true | true  | true  | false | false |
540
     * |------|------|------|------|------|-------|-------|-------|-------|
541
     * | aa   |  aaa |  aaa | aaa  |  a   |  aa   |    aa | a     |     a |
542
     * |  bbb | bb   |  bbb |  b   | bbb  |    bb |  bb   |     b | a     |
543
     * ```
544
     */
545
    public rangesIntersectOrTouch(a: Range, b: Range) {
546
        //stop if the either range is misisng
547
        if (!a || !b) {
25✔
548
            return false;
2✔
549
        }
550
        // Check if `a` is before `b`
551
        if (a.end.line < b.start.line || (a.end.line === b.start.line && a.end.character < b.start.character)) {
23✔
552
            return false;
2✔
553
        }
554

555
        // Check if `b` is before `a`
556
        if (b.end.line < a.start.line || (b.end.line === a.start.line && b.end.character < a.start.character)) {
21✔
557
            return false;
2✔
558
        }
559

560
        // These ranges must intersect
561
        return true;
19✔
562
    }
563

564
    /**
565
     * Test if `position` is in `range`. If the position is at the edges, will return true.
566
     * Adapted from core vscode
567
     */
568
    public rangeContains(range: Range, position: Position) {
569
        return this.comparePositionToRange(position, range) === 0;
9,935✔
570
    }
571

572
    public comparePositionToRange(position: Position, range: Range) {
573
        //stop if the either range is misisng
574
        if (!position || !range) {
10,284✔
575
            return 0;
2✔
576
        }
577

578
        if (position.line < range.start.line || (position.line === range.start.line && position.character < range.start.character)) {
10,282✔
579
            return -1;
647✔
580
        }
581
        if (position.line > range.end.line || (position.line === range.end.line && position.character > range.end.character)) {
9,635✔
582
            return 1;
7,131✔
583
        }
584
        return 0;
2,504✔
585
    }
586

587
    /**
588
     * Parse an xml file and get back a javascript object containing its results
589
     */
590
    public parseXml(text: string) {
591
        return new Promise<any>((resolve, reject) => {
×
592
            xml2js.parseString(text, (err, data) => {
×
593
                if (err) {
×
594
                    reject(err);
×
595
                } else {
596
                    resolve(data);
×
597
                }
598
            });
599
        });
600
    }
601

602
    public propertyCount(object: Record<string, unknown>) {
603
        let count = 0;
×
604
        for (let key in object) {
×
605
            if (object.hasOwnProperty(key)) {
×
606
                count++;
×
607
            }
608
        }
609
        return count;
×
610
    }
611

612
    public padLeft(subject: string, totalLength: number, char: string) {
613
        totalLength = totalLength > 1000 ? 1000 : totalLength;
1!
614
        while (subject.length < totalLength) {
1✔
615
            subject = char + subject;
1,000✔
616
        }
617
        return subject;
1✔
618
    }
619

620
    /**
621
     * Given a URI, convert that to a regular fs path
622
     */
623
    public uriToPath(uri: string) {
624
        let parsedPath = URI.parse(uri).fsPath;
41✔
625

626
        //Uri annoyingly coverts all drive letters to lower case...so this will bring back whatever case it came in as
627
        let match = /\/\/\/([a-z]:)/i.exec(uri);
41✔
628
        if (match) {
41✔
629
            let originalDriveCasing = match[1];
16✔
630
            parsedPath = originalDriveCasing + parsedPath.substring(2);
16✔
631
        }
632
        const normalizedPath = path.normalize(parsedPath);
41✔
633
        return normalizedPath;
41✔
634
    }
635

636
    /**
637
     * Force the drive letter to lower case
638
     */
639
    public driveLetterToLower(fullPath: string) {
640
        if (fullPath) {
9,987✔
641
            let firstCharCode = fullPath.charCodeAt(0);
9,815✔
642
            if (
9,815✔
643
                //is upper case A-Z
644
                firstCharCode >= 65 && firstCharCode <= 90 &&
19,855✔
645
                //next char is colon
646
                fullPath[1] === ':'
647
            ) {
648
                fullPath = fullPath[0].toLowerCase() + fullPath.substring(1);
243✔
649
            }
650
        }
651
        return fullPath;
9,987✔
652
    }
653

654
    /**
655
     * Replace the first instance of `search` in `subject` with `replacement`
656
     */
657
    public replaceCaseInsensitive(subject: string, search: string, replacement: string) {
658
        let idx = subject.toLowerCase().indexOf(search.toLowerCase());
858✔
659
        if (idx > -1) {
858!
660
            let result = subject.substring(0, idx) + replacement + subject.substring(idx + search.length);
858✔
661
            return result;
858✔
662
        } else {
663
            return subject;
×
664
        }
665
    }
666

667
    /**
668
     * Determine if two arrays containing primitive values are equal.
669
     * This considers order and compares by equality.
670
     */
671
    public areArraysEqual(arr1: any[], arr2: any[]) {
672
        if (arr1.length !== arr2.length) {
8✔
673
            return false;
3✔
674
        }
675
        for (let i = 0; i < arr1.length; i++) {
5✔
676
            if (arr1[i] !== arr2[i]) {
7✔
677
                return false;
3✔
678
            }
679
        }
680
        return true;
2✔
681
    }
682

683
    /**
684
     * Given a file path, convert it to a URI string
685
     */
686
    public pathToUri(filePath: string) {
687
        return URI.file(filePath).toString();
60✔
688
    }
689

690
    /**
691
     * Get the outDir from options, taking into account cwd and absolute outFile paths
692
     */
693
    public getOutDir(options: BsConfig) {
694
        options = this.normalizeConfig(options);
6✔
695
        let cwd = path.normalize(options.cwd ? options.cwd : process.cwd());
6!
696
        if (path.isAbsolute(options.outFile)) {
6!
697
            return path.dirname(options.outFile);
×
698
        } else {
699
            return path.normalize(path.join(cwd, path.dirname(options.outFile)));
6✔
700
        }
701
    }
702

703
    /**
704
     * Get paths to all files on disc that match this project's source list
705
     */
706
    public async getFilePaths(options: BsConfig) {
707
        let rootDir = this.getRootDir(options);
42✔
708

709
        let files = await rokuDeploy.getFilePaths(options.files, rootDir);
42✔
710
        return files;
42✔
711
    }
712

713
    /**
714
     * Given a path to a brs file, compute the path to a theoretical d.bs file.
715
     * Only `.brs` files can have typedef path, so return undefined for everything else
716
     */
717
    public getTypedefPath(brsSrcPath: string) {
718
        const typedefPath = brsSrcPath
1,652✔
719
            .replace(/\.brs$/i, '.d.bs')
720
            .toLowerCase();
721

722
        if (typedefPath.endsWith('.d.bs')) {
1,652✔
723
            return typedefPath;
1,003✔
724
        } else {
725
            return undefined;
649✔
726
        }
727
    }
728

729
    /**
730
     * Determine whether this diagnostic should be supressed or not, based on brs comment-flags
731
     */
732
    public diagnosticIsSuppressed(diagnostic: BsDiagnostic) {
733
        const diagnosticCode = typeof diagnostic.code === 'string' ? diagnostic.code.toLowerCase() : diagnostic.code;
299✔
734
        for (let flag of diagnostic.file?.commentFlags ?? []) {
299✔
735
            //this diagnostic is affected by this flag
736
            if (diagnostic.range && this.rangeContains(flag.affectedRange, diagnostic.range.start)) {
40✔
737
                //if the flag acts upon this diagnostic's code
738
                if (flag.codes === null || flag.codes.includes(diagnosticCode)) {
31✔
739
                    return true;
25✔
740
                }
741
            }
742
        }
743
    }
744

745
    /**
746
     * Walks up the chain to find the closest bsconfig.json file
747
     */
748
    public async findClosestConfigFile(currentPath: string) {
749
        //make the path absolute
750
        currentPath = path.resolve(
6✔
751
            path.normalize(
752
                currentPath
753
            )
754
        );
755

756
        let previousPath: string;
757
        //using ../ on the root of the drive results in the same file path, so that's how we know we reached the top
758
        while (previousPath !== currentPath) {
6✔
759
            previousPath = currentPath;
24✔
760

761
            let bsPath = path.join(currentPath, 'bsconfig.json');
24✔
762
            let brsPath = path.join(currentPath, 'brsconfig.json');
24✔
763
            if (await this.pathExists(bsPath)) {
24✔
764
                return bsPath;
2✔
765
            } else if (await this.pathExists(brsPath)) {
22✔
766
                return brsPath;
2✔
767
            } else {
768
                //walk upwards one directory
769
                currentPath = path.resolve(path.join(currentPath, '../'));
20✔
770
            }
771
        }
772
        //got to the root path, no config file exists
773
    }
774

775
    /**
776
     * Set a timeout for the specified milliseconds, and resolve the promise once the timeout is finished.
777
     * @param milliseconds the minimum number of milliseconds to sleep for
778
     */
779
    public sleep(milliseconds: number) {
780
        return new Promise((resolve) => {
88✔
781
            //if milliseconds is 0, don't actually timeout (improves unit test throughput)
782
            if (milliseconds === 0) {
88✔
783
                process.nextTick(resolve);
75✔
784
            } else {
785
                setTimeout(resolve, milliseconds);
13✔
786
            }
787
        });
788
    }
789

790
    /**
791
     * Given an array, map and then flatten
792
     * @param array the array to flatMap over
793
     * @param callback a function that is called for every array item
794
     */
795
    public flatMap<T, R>(array: T[], callback: (arg: T) => R) {
796
        return Array.prototype.concat.apply([], array.map(callback)) as never as R;
89✔
797
    }
798

799
    /**
800
     * Determines if the position is greater than the range. This means
801
     * the position does not touch the range, and has a position greater than the end
802
     * of the range. A position that touches the last line/char of a range is considered greater
803
     * than the range, because the `range.end` is EXclusive
804
     */
805
    public positionIsGreaterThanRange(position: Position, range: Range) {
806

807
        //if the position is a higher line than the range
808
        if (position.line > range.end.line) {
1,272✔
809
            return true;
1,118✔
810
        } else if (position.line < range.end.line) {
154✔
811
            return false;
14✔
812
        }
813
        //they are on the same line
814

815
        //if the position's char is greater than or equal to the range's
816
        if (position.character >= range.end.character) {
140✔
817
            return true;
131✔
818
        } else {
819
            return false;
9✔
820
        }
821
    }
822

823
    /**
824
     * Get a location object back by extracting location information from other objects that contain location
825
     */
826
    public getRange(startObj: { range: Range }, endObj: { range: Range }): Range {
827
        return util.createRangeFromPositions(startObj.range.start, endObj.range.end);
185✔
828
    }
829

830
    /**
831
     * If the two items both start on the same line
832
     */
833
    public sameStartLine(first: { range: Range }, second: { range: Range }) {
834
        if (first && second && first.range.start.line === second.range.start.line) {
×
835
            return true;
×
836
        } else {
837
            return false;
×
838
        }
839
    }
840

841
    /**
842
     * If the two items have lines that touch
843
     */
844
    public linesTouch(first: { range: Range }, second: { range: Range }) {
845
        if (first && second && (
163✔
846
            first.range.start.line === second.range.start.line ||
847
            first.range.start.line === second.range.end.line ||
848
            first.range.end.line === second.range.start.line ||
849
            first.range.end.line === second.range.end.line
850
        )) {
851
            return true;
81✔
852
        } else {
853
            return false;
82✔
854
        }
855
    }
856

857
    /**
858
     * Given text with (or without) dots separating text, get the rightmost word.
859
     * (i.e. given "A.B.C", returns "C". or "B" returns "B because there's no dot)
860
     */
861
    public getTextAfterFinalDot(name: string) {
862
        if (name) {
168!
863
            let parts = name.split('.');
168✔
864
            if (parts.length > 0) {
168!
865
                return parts[parts.length - 1];
168✔
866
            }
867
        }
868
    }
869

870
    /**
871
     * Find a script import that the current position touches, or undefined if not found
872
     */
873
    public getScriptImportAtPosition(scriptImports: FileReference[], position: Position) {
874
        let scriptImport = scriptImports.find((x) => {
74✔
875
            return x.filePathRange.start.line === position.line &&
4✔
876
                //column between start and end
877
                position.character >= x.filePathRange.start.character &&
878
                position.character <= x.filePathRange.end.character;
879
        });
880
        return scriptImport;
74✔
881
    }
882

883
    /**
884
     * Given the class name text, return a namespace-prefixed name.
885
     * If the name already has a period in it, or the namespaceName was not provided, return the class name as is.
886
     * If the name does not have a period, and a namespaceName was provided, return the class name prepended by the namespace name.
887
     * If no namespace is provided, return the `className` unchanged.
888
     */
889
    public getFullyQualifiedClassName(className: string, namespaceName?: string) {
890
        if (className?.includes('.') === false && namespaceName) {
81,092✔
891
            return `${namespaceName}.${className}`;
134✔
892
        } else {
893
            return className;
80,958✔
894
        }
895
    }
896

897
    public splitIntoLines(string: string) {
898
        return string.split(/\r?\n/g);
169✔
899
    }
900

901
    public getTextForRange(string: string | string[], range: Range) {
902
        let lines: string[];
903
        if (Array.isArray(string)) {
171✔
904
            lines = string;
170✔
905
        } else {
906
            lines = this.splitIntoLines(string);
1✔
907
        }
908

909
        const start = range.start;
171✔
910
        const end = range.end;
171✔
911

912
        let endCharacter = end.character;
171✔
913
        // If lines are the same we need to subtract out our new starting position to make it work correctly
914
        if (start.line === end.line) {
171✔
915
            endCharacter -= start.character;
1✔
916
        }
917

918
        let rangeLines = [lines[start.line].substring(start.character)];
171✔
919
        for (let i = start.line + 1; i <= end.line; i++) {
171✔
920
            rangeLines.push(lines[i]);
170✔
921
        }
922
        const lastLine = rangeLines.pop();
171✔
923
        rangeLines.push(lastLine.substring(0, endCharacter));
171✔
924
        return rangeLines.join('\n');
171✔
925
    }
926

927
    /**
928
     * Helper for creating `Location` objects. Prefer using this function because vscode-languageserver's `Location.create()` is significantly slower at scale
929
     */
930
    public createLocation(uri: string, range: Range): Location {
931
        return {
114✔
932
            uri: uri,
933
            range: range
934
        };
935
    }
936

937
    /**
938
     * Helper for creating `Range` objects. Prefer using this function because vscode-languageserver's `Range.create()` is significantly slower
939
     */
940
    public createRange(startLine: number, startCharacter: number, endLine: number, endCharacter: number): Range {
941
        return {
60,155✔
942
            start: {
943
                line: startLine,
944
                character: startCharacter
945
            },
946
            end: {
947
                line: endLine,
948
                character: endCharacter
949
            }
950
        };
951
    }
952

953
    /**
954
     * Create a `Range` from two `Position`s
955
     */
956
    public createRangeFromPositions(startPosition: Position, endPosition: Position): Range {
957
        return {
13,027✔
958
            start: {
959
                line: startPosition.line,
960
                character: startPosition.character
961
            },
962
            end: {
963
                line: endPosition.line,
964
                character: endPosition.character
965
            }
966
        };
967
    }
968

969
    /**
970
     * Given a list of ranges, create a range that starts with the first non-null lefthand range, and ends with the first non-null
971
     * righthand range. Returns undefined if none of the items have a range.
972
     */
973
    public createBoundingRange(...locatables: Array<{ range?: Range }>) {
974
        let leftmostRange: Range;
975
        let rightmostRange: Range;
976

977
        for (let i = 0; i < locatables.length; i++) {
12,349✔
978
            //set the leftmost non-null-range item
979
            const left = locatables[i];
13,513✔
980
            //the range might be a getter, so access it exactly once
981
            const leftRange = left?.range;
13,513✔
982
            if (!leftmostRange && leftRange) {
13,513✔
983
                leftmostRange = leftRange;
12,349✔
984
            }
985

986
            //set the rightmost non-null-range item
987
            const right = locatables[locatables.length - 1 - i];
13,513✔
988
            //the range might be a getter, so access it exactly once
989
            const rightRange = right?.range;
13,513✔
990
            if (!rightmostRange && rightRange) {
13,513✔
991
                rightmostRange = rightRange;
12,349✔
992
            }
993

994
            //if we have both sides, quit
995
            if (leftmostRange && rightmostRange) {
13,513✔
996
                break;
12,349✔
997
            }
998
        }
999
        if (leftmostRange) {
12,349!
1000
            return this.createRangeFromPositions(leftmostRange.start, rightmostRange.end);
12,349✔
1001
        } else {
1002
            return undefined;
×
1003
        }
1004
    }
1005

1006
    /**
1007
     * Create a `Position` object. Prefer this over `Position.create` for performance reasons
1008
     */
1009
    public createPosition(line: number, character: number) {
1010
        return {
241✔
1011
            line: line,
1012
            character: character
1013
        };
1014
    }
1015

1016
    /**
1017
     * Convert a list of tokens into a string, including their leading whitespace
1018
     */
1019
    public tokensToString(tokens: Token[]) {
1020
        let result = '';
1✔
1021
        //skip iterating the final token
1022
        for (let token of tokens) {
1✔
1023
            result += token.leadingWhitespace + token.text;
16✔
1024
        }
1025
        return result;
1✔
1026
    }
1027

1028
    /**
1029
     * Convert a token into a BscType
1030
     */
1031
    public tokenToBscType(token: Token, allowCustomType = true) {
3,052✔
1032
        // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
1033
        switch (token.kind) {
3,368✔
1034
            case TokenKind.Boolean:
6,434✔
1035
                return new BooleanType(token.text);
18✔
1036
            case TokenKind.True:
1037
            case TokenKind.False:
1038
                return new BooleanType();
648✔
1039
            case TokenKind.Double:
1040
                return new DoubleType(token.text);
12✔
1041
            case TokenKind.DoubleLiteral:
1042
                return new DoubleType();
15✔
1043
            case TokenKind.Dynamic:
1044
                return new DynamicType(token.text);
24✔
1045
            case TokenKind.Float:
1046
                return new FloatType(token.text);
14✔
1047
            case TokenKind.FloatLiteral:
1048
                return new FloatType();
48✔
1049
            case TokenKind.Function:
1050
                //TODO should there be a more generic function type without a signature that's assignable to all other function types?
1051
                return new FunctionType(new DynamicType(token.text));
18✔
1052
            case TokenKind.Integer:
1053
                return new IntegerType(token.text);
141✔
1054
            case TokenKind.IntegerLiteral:
1055
                return new IntegerType();
863✔
1056
            case TokenKind.Invalid:
1057
                return new InvalidType(token.text);
49✔
1058
            case TokenKind.LongInteger:
1059
                return new LongIntegerType(token.text);
12✔
1060
            case TokenKind.LongIntegerLiteral:
1061
                return new LongIntegerType();
2✔
1062
            case TokenKind.Object:
1063
                return new ObjectType(token.text);
48✔
1064
            case TokenKind.String:
1065
                return new StringType(token.text);
375✔
1066
            case TokenKind.StringLiteral:
1067
            case TokenKind.TemplateStringExpressionBegin:
1068
            case TokenKind.TemplateStringExpressionEnd:
1069
            case TokenKind.TemplateStringQuasi:
1070
                return new StringType();
932✔
1071
            case TokenKind.Void:
1072
                return new VoidType(token.text);
12✔
1073
            case TokenKind.Identifier:
1074
                switch (token.text.toLowerCase()) {
136✔
1075
                    case 'boolean':
36!
1076
                        return new BooleanType(token.text);
×
1077
                    case 'double':
1078
                        return new DoubleType(token.text);
×
1079
                    case 'float':
1080
                        return new FloatType(token.text);
×
1081
                    case 'function':
1082
                        return new FunctionType(new DynamicType(token.text));
×
1083
                    case 'integer':
1084
                        return new IntegerType(token.text);
24✔
1085
                    case 'invalid':
1086
                        return new InvalidType(token.text);
×
1087
                    case 'longinteger':
1088
                        return new LongIntegerType(token.text);
×
1089
                    case 'object':
1090
                        return new ObjectType(token.text);
2✔
1091
                    case 'string':
1092
                        return new StringType(token.text);
8✔
1093
                    case 'void':
1094
                        return new VoidType(token.text);
2✔
1095
                }
1096
                if (allowCustomType) {
100✔
1097
                    return new CustomType(token.text);
97✔
1098
                }
1099
        }
1100
    }
1101

1102
    /**
1103
     * Get the extension for the given file path. Basically the part after the final dot, except for
1104
     * `d.bs` which is treated as single extension
1105
     */
1106
    public getExtension(filePath: string) {
1107
        filePath = filePath.toLowerCase();
1,252✔
1108
        if (filePath.endsWith('.d.bs')) {
1,252✔
1109
            return '.d.bs';
25✔
1110
        } else {
1111
            const idx = filePath.lastIndexOf('.');
1,227✔
1112
            if (idx > -1) {
1,227✔
1113
                return filePath.substring(idx);
1,136✔
1114
            }
1115
        }
1116
    }
1117

1118
    /**
1119
     * Load and return the list of plugins
1120
     */
1121
    public loadPlugins(cwd: string, pathOrModules: string[], onError?: (pathOrModule: string, err: Error) => void) {
1122
        const logger = new Logger();
47✔
1123
        return pathOrModules.reduce<CompilerPlugin[]>((acc, pathOrModule) => {
47✔
1124
            if (typeof pathOrModule === 'string') {
6!
1125
                try {
6✔
1126
                    const loaded = requireRelative(pathOrModule, cwd);
6✔
1127
                    const theExport: CompilerPlugin | CompilerPluginFactory = loaded.default ? loaded.default : loaded;
6✔
1128

1129
                    let plugin: CompilerPlugin;
1130

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

1136
                        // the official plugin format is a factory function that returns a new instance of a plugin.
1137
                    } else if (typeof theExport === 'function') {
4!
1138
                        plugin = theExport();
4✔
1139
                    }
1140

1141
                    if (!plugin.name) {
6!
1142
                        plugin.name = pathOrModule;
×
1143
                    }
1144
                    acc.push(plugin);
6✔
1145
                } catch (err: any) {
1146
                    if (onError) {
×
1147
                        onError(pathOrModule, err);
×
1148
                    } else {
1149
                        throw err;
×
1150
                    }
1151
                }
1152
            }
1153
            return acc;
6✔
1154
        }, []);
1155
    }
1156

1157
    /**
1158
     * Gathers expressions, variables, and unique names from an expression.
1159
     * This is mostly used for the ternary expression
1160
     */
1161
    public getExpressionInfo(expression: Expression): ExpressionInfo {
1162
        const expressions = [expression];
58✔
1163
        const variableExpressions = [] as VariableExpression[];
58✔
1164
        const uniqueVarNames = new Set<string>();
58✔
1165

1166
        function expressionWalker(expression) {
1167
            if (isExpression(expression)) {
88✔
1168
                expressions.push(expression);
84✔
1169
            }
1170
            if (isVariableExpression(expression)) {
88✔
1171
                variableExpressions.push(expression);
18✔
1172
                uniqueVarNames.add(expression.name.text);
18✔
1173
            }
1174
        }
1175

1176
        // Collect all expressions. Most of these expressions are fairly small so this should be quick!
1177
        // This should only be called during transpile time and only when we actually need it.
1178
        expression?.walk(expressionWalker, {
58✔
1179
            walkMode: WalkMode.visitExpressions
1180
        });
1181

1182
        //handle the expression itself (for situations when expression is a VariableExpression)
1183
        expressionWalker(expression);
58✔
1184

1185
        return { expressions: expressions, varExpressions: variableExpressions, uniqueVarNames: [...uniqueVarNames] };
58✔
1186
    }
1187

1188

1189
    /**
1190
     * Create a SourceNode that maps every line to itself. Useful for creating maps for files
1191
     * that haven't changed at all, but we still need the map
1192
     */
1193
    public simpleMap(source: string, src: string) {
1194
        //create a source map from the original source code
1195
        let chunks = [] as (SourceNode | string)[];
5✔
1196
        let lines = src.split(/\r?\n/g);
5✔
1197
        for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
5✔
1198
            let line = lines[lineIndex];
19✔
1199
            chunks.push(
19✔
1200
                lineIndex > 0 ? '\n' : '',
19✔
1201
                new SourceNode(lineIndex + 1, 0, source, line)
1202
            );
1203
        }
1204
        return new SourceNode(null, null, source, chunks);
5✔
1205
    }
1206

1207
    /**
1208
     * Creates a new SGAttribute object, but keeps the existing Range references (since those shouldn't ever get changed directly)
1209
     */
1210
    public cloneSGAttribute(attr: SGAttribute, value: string) {
1211
        return {
13✔
1212
            key: {
1213
                text: attr.key.text,
1214
                range: attr.range
1215
            },
1216
            value: {
1217
                text: value,
1218
                range: attr.value.range
1219
            },
1220
            range: attr.range
1221
        } as SGAttribute;
1222
    }
1223

1224
    /**
1225
     * Converts a path into a standardized format (drive letter to lower, remove extra slashes, use single slash type, resolve relative parts, etc...)
1226
     */
1227
    public standardizePath(thePath: string) {
1228
        return util.driveLetterToLower(
4,460✔
1229
            rokuDeployStandardizePath(thePath)
1230
        );
1231
    }
1232

1233
    /**
1234
     * Copy the version of bslib from local node_modules to the staging folder
1235
     */
1236
    public async copyBslibToStaging(stagingDir: string, bslibDestinationDir = 'source') {
1✔
1237
        //copy bslib to the output directory
1238
        await fsExtra.ensureDir(standardizePath(`${stagingDir}/${bslibDestinationDir}`));
32✔
1239
        // eslint-disable-next-line
1240
        const bslib = require('@rokucommunity/bslib');
32✔
1241
        let source = bslib.source as string;
32✔
1242

1243
        //apply the `bslib_` prefix to the functions
1244
        let match: RegExpExecArray;
1245
        const positions = [] as number[];
32✔
1246
        const regexp = /^(\s*(?:function|sub)\s+)([a-z0-9_]+)/mg;
32✔
1247
        // eslint-disable-next-line no-cond-assign
1248
        while (match = regexp.exec(source)) {
32✔
1249
            positions.push(match.index + match[1].length);
96✔
1250
        }
1251

1252
        for (let i = positions.length - 1; i >= 0; i--) {
32✔
1253
            const position = positions[i];
96✔
1254
            source = source.slice(0, position) + 'bslib_' + source.slice(position);
96✔
1255
        }
1256
        await fsExtra.writeFile(`${stagingDir}/${bslibDestinationDir}/bslib.brs`, source);
32✔
1257
    }
1258

1259
    /**
1260
     * Given a Diagnostic or BsDiagnostic, return a deep clone of the diagnostic.
1261
     * @param diagnostic the diagnostic to clone
1262
     * @param relatedInformationFallbackLocation a default location to use for all `relatedInformation` entries that are missing a location
1263
     */
1264
    public toDiagnostic(diagnostic: Diagnostic | BsDiagnostic, relatedInformationFallbackLocation: string) {
1265
        return {
7✔
1266
            severity: diagnostic.severity,
1267
            range: diagnostic.range,
1268
            message: diagnostic.message,
1269
            relatedInformation: diagnostic.relatedInformation?.map(x => {
21✔
1270

1271
                //clone related information just in case a plugin added circular ref info here
1272
                const clone = { ...x };
4✔
1273
                if (!clone.location) {
4✔
1274
                    // use the fallback location if available
1275
                    if (relatedInformationFallbackLocation) {
2✔
1276
                        clone.location = util.createLocation(relatedInformationFallbackLocation, diagnostic.range);
1✔
1277
                    } else {
1278
                        //remove this related information so it doesn't bring crash the language server
1279
                        return undefined;
1✔
1280
                    }
1281
                }
1282
                return clone;
3✔
1283
                //filter out null relatedInformation items
1284
            }).filter(x => x),
4✔
1285
            code: diagnostic.code,
1286
            source: 'brs'
1287
        };
1288
    }
1289

1290
    /**
1291
     * Get the first locatable item found at the specified position
1292
     * @param locatables an array of items that have a `range` property
1293
     * @param position the position that the locatable must contain
1294
     */
1295
    public getFirstLocatableAt(locatables: Locatable[], position: Position) {
1296
        for (let token of locatables) {
×
1297
            if (util.rangeContains(token.range, position)) {
×
1298
                return token;
×
1299
            }
1300
        }
1301
    }
1302

1303
    /**
1304
     * Sort an array of objects that have a Range
1305
     */
1306
    public sortByRange<T extends Locatable>(locatables: T[]) {
1307
        //sort the tokens by range
1308
        return locatables.sort((a, b) => {
34✔
1309
            //start line
1310
            if (a.range.start.line < b.range.start.line) {
77✔
1311
                return -1;
11✔
1312
            }
1313
            if (a.range.start.line > b.range.start.line) {
66✔
1314
                return 1;
11✔
1315
            }
1316
            //start char
1317
            if (a.range.start.character < b.range.start.character) {
55✔
1318
                return -1;
4✔
1319
            }
1320
            if (a.range.start.character > b.range.start.character) {
51!
1321
                return 1;
51✔
1322
            }
1323
            //end line
1324
            if (a.range.end.line < b.range.end.line) {
×
1325
                return -1;
×
1326
            }
1327
            if (a.range.end.line > b.range.end.line) {
×
1328
                return 1;
×
1329
            }
1330
            //end char
1331
            if (a.range.end.character < b.range.end.character) {
×
1332
                return -1;
×
1333
            } else if (a.range.end.character > b.range.end.character) {
×
1334
                return 1;
×
1335
            }
1336
            return 0;
×
1337
        });
1338
    }
1339

1340
    /**
1341
     * Split the given text and return ranges for each chunk.
1342
     * Only works for single-line strings
1343
     */
1344
    public splitGetRange(separator: string, text: string, range: Range) {
1345
        const chunks = text.split(separator);
3✔
1346
        const result = [] as Array<{ text: string; range: Range }>;
3✔
1347
        let offset = 0;
3✔
1348
        for (let chunk of chunks) {
3✔
1349
            //only keep nonzero chunks
1350
            if (chunk.length > 0) {
8✔
1351
                result.push({
7✔
1352
                    text: chunk,
1353
                    range: this.createRange(
1354
                        range.start.line,
1355
                        range.start.character + offset,
1356
                        range.end.line,
1357
                        range.start.character + offset + chunk.length
1358
                    )
1359
                });
1360
            }
1361
            offset += chunk.length + separator.length;
8✔
1362
        }
1363
        return result;
3✔
1364
    }
1365

1366
    /**
1367
     * Wrap the given code in a markdown code fence (with the language)
1368
     */
1369
    public mdFence(code: string, language = '') {
×
1370
        return '```' + language + '\n' + code + '\n```';
35✔
1371
    }
1372

1373
    /**
1374
     * Gets each part of the dotted get.
1375
     * @param node any ast expression
1376
     * @returns an array of the parts of the dotted get. If not fully a dotted get, then returns undefined
1377
     */
1378
    public getAllDottedGetParts(node: Expression | Statement): Identifier[] | undefined {
1379
        const parts: Identifier[] = [];
91✔
1380
        let nextPart = node;
91✔
1381
        while (nextPart) {
91✔
1382
            if (isAssignmentStatement(node)) {
140✔
1383
                return [node.name];
10✔
1384
            } else if (isDottedGetExpression(nextPart)) {
130✔
1385
                parts.push(nextPart?.name);
47!
1386
                nextPart = nextPart.obj;
47✔
1387
            } else if (isNamespacedVariableNameExpression(nextPart)) {
83✔
1388
                nextPart = nextPart.expression;
2✔
1389
            } else if (isVariableExpression(nextPart)) {
81✔
1390
                parts.push(nextPart?.name);
45!
1391
                break;
45✔
1392
            } else if (isFunctionParameterExpression(nextPart)) {
36✔
1393
                return [nextPart.name];
10✔
1394
            } else {
1395
                //we found a non-DottedGet expression, so return because this whole operation is invalid.
1396
                return undefined;
26✔
1397
            }
1398
        }
1399
        return parts.reverse();
45✔
1400
    }
1401

1402
    /**
1403
     * Break an expression into each part.
1404
     */
1405
    public splitExpression(expression: Expression) {
1406
        const parts: Expression[] = [expression];
1,077✔
1407
        let nextPart = expression;
1,077✔
1408
        while (nextPart) {
1,077✔
1409
            if (isDottedGetExpression(nextPart) || isIndexedGetExpression(nextPart) || isXmlAttributeGetExpression(nextPart)) {
1,490✔
1410
                nextPart = nextPart.obj;
247✔
1411

1412
            } else if (isCallExpression(nextPart) || isCallfuncExpression(nextPart)) {
1,243✔
1413
                nextPart = nextPart.callee;
166✔
1414

1415
            } else if (isNamespacedVariableNameExpression(nextPart)) {
1,077!
1416
                nextPart = nextPart.expression;
×
1417
            } else {
1418
                break;
1,077✔
1419
            }
1420
            parts.unshift(nextPart);
413✔
1421
        }
1422
        return parts;
1,077✔
1423
    }
1424

1425
    /**
1426
     * Break an expression into each part, and return any VariableExpression or DottedGet expresisons from left-to-right.
1427
     */
1428
    public getDottedGetPath(expression: Expression): [VariableExpression, ...DottedGetExpression[]] {
1429
        let parts: Expression[] = [];
1,838✔
1430
        let nextPart = expression;
1,838✔
1431
        while (nextPart) {
1,838✔
1432
            if (isDottedGetExpression(nextPart)) {
2,607✔
1433
                parts.unshift(nextPart);
329✔
1434
                nextPart = nextPart.obj;
329✔
1435

1436
            } else if (isIndexedGetExpression(nextPart) || isXmlAttributeGetExpression(nextPart)) {
2,278✔
1437
                nextPart = nextPart.obj;
54✔
1438
                parts = [];
54✔
1439

1440
            } else if (isCallExpression(nextPart) || isCallfuncExpression(nextPart)) {
2,224✔
1441
                nextPart = nextPart.callee;
324✔
1442
                parts = [];
324✔
1443

1444
            } else if (isNewExpression(nextPart)) {
1,900✔
1445
                nextPart = nextPart.call.callee;
31✔
1446
                parts = [];
31✔
1447

1448
            } else if (isNamespacedVariableNameExpression(nextPart)) {
1,869✔
1449
                nextPart = nextPart.expression;
31✔
1450

1451
            } else if (isVariableExpression(nextPart)) {
1,838✔
1452
                parts.unshift(nextPart);
607✔
1453
                break;
607✔
1454
            } else {
1455
                parts = [];
1,231✔
1456
                break;
1,231✔
1457
            }
1458
        }
1459
        return parts as any;
1,838✔
1460
    }
1461

1462
    /**
1463
     * Returns an integer if valid, or undefined. Eliminates checking for NaN
1464
     */
1465
    public parseInt(value: any) {
1466
        const result = parseInt(value);
26✔
1467
        if (!isNaN(result)) {
26✔
1468
            return result;
21✔
1469
        } else {
1470
            return undefined;
5✔
1471
        }
1472
    }
1473

1474
    /**
1475
     * Converts a range to a string in the format 1:2-3:4
1476
     */
1477
    public rangeToString(range: Range) {
1478
        return `${range?.start?.line}:${range?.start?.character}-${range?.end?.line}:${range?.end?.character}`;
89!
1479
    }
1480

1481
    public validateTooDeepFile(file: (BrsFile | XmlFile)) {
1482
        //find any files nested too deep
1483
        let pkgPath = file.pkgPath ?? file.pkgPath.toString();
748!
1484
        let rootFolder = pkgPath.replace(/^pkg:/, '').split(/[\\\/]/)[0].toLowerCase();
748✔
1485

1486
        if (isBrsFile(file) && rootFolder !== 'source') {
748✔
1487
            return;
74✔
1488
        }
1489

1490
        if (isXmlFile(file) && rootFolder !== 'components') {
674!
1491
            return;
×
1492
        }
1493

1494
        let fileDepth = this.getParentDirectoryCount(pkgPath);
674✔
1495
        if (fileDepth >= 8) {
674✔
1496
            file.addDiagnostics([{
3✔
1497
                ...DiagnosticMessages.detectedTooDeepFileSource(fileDepth),
1498
                file: file,
1499
                range: this.createRange(0, 0, 0, Number.MAX_VALUE)
1500
            }]);
1501
        }
1502
    }
1503
}
1504

1505
/**
1506
 * 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,
1507
 * we can't use `object.tag` syntax.
1508
 */
1509
export function standardizePath(stringParts, ...expressions: any[]) {
1✔
1510
    let result = [];
5,525✔
1511
    for (let i = 0; i < stringParts.length; i++) {
5,525✔
1512
        result.push(stringParts[i], expressions[i]);
14,973✔
1513
    }
1514
    return util.driveLetterToLower(
5,525✔
1515
        rokuDeployStandardizePath(
1516
            result.join('')
1517
        )
1518
    );
1519
}
1520

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

© 2025 Coveralls, Inc