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

rokucommunity / bslint / 26774958230

01 Jun 2026 06:48PM UTC coverage: 92.031% (-0.3%) from 92.308%
26774958230

Pull #96

github

web-flow
Merge 5e3b3f43f into 1dcfe326c
Pull Request #96: v1

1033 of 1172 branches covered (88.14%)

Branch coverage included in aggregate %.

302 of 312 new or added lines in 13 files covered. (96.79%)

14 existing lines in 2 files now uncovered.

1115 of 1162 relevant lines covered (95.96%)

85.4 hits per line

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

93.84
/src/plugins/trackCodeFlow/varTracking.ts
1
import { BscFile, FunctionExpression, BsDiagnostic, Range, isForStatement, isForEachStatement, isIfStatement, isAssignmentStatement, isNamespaceStatement, NamespaceStatement, Expression, isVariableExpression, isBinaryExpression, TokenKind, Scope, CallableContainerMap, DiagnosticSeverity, isLiteralInvalid, isWhileStatement, isCatchStatement, isLabelStatement, isGotoStatement, ParseMode, util, isMethodStatement, isTryCatchStatement, isConditionalCompileStatement, VariableExpression } from 'brighterscript';
1✔
2
import { LintState, StatementInfo, NarrowingInfo, VarInfo, VarRestriction } from '.';
1✔
3
import { PluginContext } from '../../util';
4
import { Location } from 'vscode-languageserver-types';
1✔
5

6
export enum VarLintError {
1✔
7
    UninitializedVar = 'LINT1001',
1✔
8
    UnsafeIteratorVar = 'LINT1002',
1✔
9
    UnsafeInitialization = 'LINT1003',
1✔
10
    CaseMismatch = 'LINT1004',
1✔
11
    UnusedVariable = 'LINT1005',
1✔
12
    UnusedParameter = 'LINT1006'
1✔
13
}
14

15
enum ValidationKind {
1✔
16
    Assignment = 'Assignment',
1✔
17
    UninitializedVar = 'UninitializedVar',
1✔
18
    Unsafe = 'Unsafe'
1✔
19
}
20

21

22
export const VarTrackingMessages = {
1✔
23
    varCasing: (curr: VarInfo, name: { text: string; location: Location }) => ({
12✔
24
        severity: DiagnosticSeverity.Warning,
25
        source: 'bslint',
26
        code: VarLintError.CaseMismatch,
27
        message: `Variable '${name.text}' was previously set with a different casing as '${curr.name}'`,
28
        data: {
29
            name: curr.name,
30
            location: name.location
31
        }
32
    }),
33
    unsafeIterator: (name: string) => ({
2✔
34
        severity: DiagnosticSeverity.Error,
35
        source: 'bslint',
36
        code: VarLintError.UnsafeIteratorVar,
37
        message: `Using iterator variable '${name}' outside loop`
38
    }),
39
    unusedVariable: (name: string) => ({
51✔
40
        severity: DiagnosticSeverity.Warning,
41
        source: 'bslint',
42
        code: VarLintError.UnusedVariable,
43
        message: `Variable '${name}' is set but value is never used`
44
    }),
45
    unsafeInitialization: (name: string) => ({
19✔
46
        severity: DiagnosticSeverity.Error,
47
        source: 'bslint',
48
        code: VarLintError.UnsafeInitialization,
49
        message: `Not all the code paths assign '${name}'`
50
    }),
51
    uninitializedVariable: (name: string, scopeName: string) => ({
10✔
52
        severity: DiagnosticSeverity.Error,
53
        source: 'bslint',
54
        code: VarLintError.UninitializedVar,
55
        message: `Using uninitialised variable '${name}' when this file is included in scope '${scopeName}'`
56
    })
57
};
58

59

60
interface ValidationInfo {
61
    kind: ValidationKind;
62
    name: string;
63
    local?: VarInfo;
64
    location: Location;
65
    namespace?: NamespaceStatement;
66
}
67

68
const deferredValidation: Map<string, ValidationInfo[]> = new Map();
1✔
69

70
function getDeferred(file: BscFile) {
71
    return deferredValidation.get(file.srcPath);
175✔
72
}
73

74
export function resetVarContext(file: BscFile) {
1✔
75
    deferredValidation.set(file.srcPath, []);
63✔
76
}
77

78
export function createVarLinter(
1✔
79
    lintContext: PluginContext,
80
    file: BscFile,
81
    fun: FunctionExpression,
82
    state: LintState,
83
    diagnostics: BsDiagnostic[]
84
) {
85
    const { severity } = lintContext;
175✔
86
    const deferred = getDeferred(file);
175✔
87
    let foundLabelAt = 0;
175✔
88

89
    const args: Map<string, VarInfo> = new Map();
175✔
90
    args.set('m', { name: 'm', location: Location.create('', Range.create(0, 0, 0, 0)), isParam: true, isUnsafe: false, isUsed: true });
175✔
91
    fun.parameters.forEach((p) => {
175✔
92
        const name = p.tokens.name.text;
14✔
93
        args.set(name.toLowerCase(), { name: name, location: p.tokens.name.location, isParam: true, isUnsafe: false, isUsed: false });
14✔
94
    });
95

96
    if (isMethodStatement(fun.parent)) {
175✔
97
        args.set('super', { name: 'super', location: null, isParam: true, isUnsafe: false, isUsed: true });
8✔
98
    }
99

100
    function verifyVarCasing(curr: VarInfo, name: { text: string; location: Location }) {
101
        if (curr && curr.name !== name.text) {
289✔
102
            diagnostics.push({
12✔
103
                ...VarTrackingMessages.varCasing(curr, name),
104
                severity: severity.caseSensitivity,
105
                location: name.location
106
            });
107
        }
108
    }
109

110
    function setLocal(parent: StatementInfo, name: { text: string; location: Location }, restriction?: VarRestriction): VarInfo {
111
        if (!name) {
275!
UNCOV
112
            return;
×
113
        }
114
        const key = name.text.toLowerCase();
275✔
115
        const arg = args.get(key);
275✔
116
        const local = {
275✔
117
            name: name.text,
118
            location: name.location,
119
            parent: parent,
120
            restriction: restriction,
121
            metBranches: 1,
122
            isUnsafe: false,
123
            isUsed: false
124
        };
125
        if (arg) {
275✔
126
            verifyVarCasing(arg, name);
3✔
127
            return local;
3✔
128
        }
129

130
        if (!parent.locals) {
272✔
131
            parent.locals = new Map();
207✔
132
        } else {
133
            verifyVarCasing(parent.locals.get(key), name);
65✔
134
        }
135
        parent.locals.set(key, local);
272✔
136

137
        deferred.push({
272✔
138
            kind: ValidationKind.Assignment,
139
            name: name.text,
140
            local: local,
141
            location: name.location
142
        });
143

144
        return local;
272✔
145
    }
146

147
    function findLocal(name: string): VarInfo | undefined {
148
        const key = name.toLowerCase();
320✔
149
        const arg = args.get(key);
320✔
150
        if (arg) {
320✔
151
            return arg;
10✔
152
        }
153
        const { parent, blocks, stack } = state;
310✔
154

155
        if (parent?.locals?.has(key)) {
310!
156
            return parent.locals.get(key);
133✔
157
        }
158
        for (let i = stack.length - 2; i >= 0; i--) {
177✔
159
            const block = blocks.get(stack[i]);
152✔
160
            const local = block?.locals?.get(key);
152!
161
            if (local) {
152✔
162
                return local;
85✔
163
            }
164
        }
165
        return undefined;
92✔
166
    }
167

168
    // A local was found but it is considered unsafe (e.g. set in an if branch)
169
    // Found out whether a parent has this variable set safely
170
    function findSafeLocal(name: string): VarInfo | undefined {
171
        const key = name.toLowerCase();
33✔
172
        const { blocks, stack } = state;
33✔
173
        if (stack.length < 2) {
33✔
174
            return undefined;
24✔
175
        }
176
        for (let i = stack.length - 2; i >= 0; i--) {
9✔
177
            const block = blocks.get(stack[i]);
18✔
178
            const local = block?.locals?.get(key);
18!
179
            // if partial, look up higher in the scope for a non-partial
180
            if (local && !local.isUnsafe) {
18✔
181
                return local;
2✔
182
            }
183
        }
184
    }
185

186
    function openBlock(block: StatementInfo) {
187
        const { stat } = block;
519✔
188
        if (isForStatement(stat)) {
519✔
189
            // for iterator will be declared by the next assignement statement
190
        } else if (isForEachStatement(stat)) {
485✔
191
            // declare `for each` iterator variable
192
            setLocal(block, stat.tokens.item, VarRestriction.Iterator);
14✔
193
        } else if (state.parent?.narrows) {
471✔
194
            narrowBlock(block);
10✔
195
        }
196
    }
197

198
    function narrowBlock(block: StatementInfo) {
199
        const { parent } = state;
10✔
200
        const { stat } = block;
10✔
201

202
        if (isIfStatement(stat) && isIfStatement(parent.stat)) {
10!
UNCOV
203
            block.narrows = parent?.narrows;
×
UNCOV
204
            return;
×
205
        }
206

207
        parent?.narrows?.forEach(narrow => {
10!
208
            if (narrow.block === stat) {
10✔
209
                setLocal(block, narrow).narrowed = narrow;
9✔
210
            } else {
211
                // opposite narrowing for other branches
212
                setLocal(block, narrow).narrowed = {
1✔
213
                    ...narrow,
214
                    type: narrow.type === 'invalid' ? 'valid' : 'invalid'
1!
215
                };
216
            }
217
        });
218
    }
219

220
    function visitStatement(curr: StatementInfo) {
221
        const { stat } = curr;
1,028✔
222
        if (isAssignmentStatement(stat) && state.parent) {
1,028✔
223
            // value = stat.value;
224
            setLocal(state.parent, stat.tokens.name, isForStatement(state.parent.stat) ? VarRestriction.Iterator : undefined);
248✔
225
        } else if (isCatchStatement(stat) && state.parent) {
780✔
226
            setLocal(curr, (stat.exceptionVariableExpression as VariableExpression)?.tokens?.name, VarRestriction.CatchedError);
3!
227
        } else if (isLabelStatement(stat) && !foundLabelAt) {
777✔
228
            foundLabelAt = stat.location.range.start.line;
1✔
229
        } else if (foundLabelAt && isGotoStatement(stat) && state.parent) {
776✔
230
            // To avoid false positives when finding a goto statement,
231
            // very generously mark as used all unused variables after 1st found label line.
232
            // This isn't accurate but tracking usage across goto jumps is tricky
233
            const { stack, blocks } = state;
1✔
234
            const labelLine = foundLabelAt;
1✔
235
            for (let i = state.stack.length - 1; i >= 0; i--) {
1✔
236
                const block = blocks.get(stack[i]);
3✔
237
                block?.locals?.forEach(local => {
3!
238
                    if (local.location.range?.start.line > labelLine) {
5!
239
                        local.isUsed = true;
4✔
240
                    }
241
                });
242
            }
243
        }
244
    }
245

246
    function closeBlock(closed: StatementInfo) {
247
        const { locals, branches, returns } = closed;
526✔
248
        const { parent } = state;
526✔
249
        if (!parent) {
526✔
250
            // always finalize when closing the function body (no parent)
251
            // this ensures parameters are checked even if there are no local variables
252
            finalize(locals ?? new Map());
175✔
253
            return;
175✔
254
        }
255
        if (!locals) {
351✔
256
            return;
135✔
257
        }
258
        // when closing a branched statement, evaluate vars with partial branches covered
259
        if (branches > 1) {
216✔
260
            locals.forEach((local) => {
112✔
261
                if (local.metBranches !== branches) {
123✔
262
                    local.isUnsafe = true;
105✔
263
                }
264
                local.metBranches = 1;
123✔
265
            });
266
        } else if (isIfStatement(parent.stat)) {
104✔
267
            locals.forEach(local => {
60✔
268
                // keep narrowed vars if we `return` the invalid branch
269
                if (local.narrowed) {
62✔
270
                    if (!returns || local.narrowed.type === 'valid') {
10✔
271
                        locals.delete(local.name.toLowerCase());
7✔
272
                    }
273
                }
274
            });
275
        } else if (isCatchStatement(closed.stat)) {
44✔
276
            locals.forEach(local => {
3✔
277
                // drop error variable
278
                if (local.restriction === VarRestriction.CatchedError) {
4✔
279
                    locals.delete(local.name.toLowerCase());
3✔
280
                }
281
            });
282
        }
283
        // move locals to parent
284
        if (!parent.locals) {
216✔
285
            parent.locals = locals;
113✔
286
        } else {
287
            const isParentBranched = isIfStatement(parent.stat) || isTryCatchStatement(parent.stat) || isConditionalCompileStatement(parent.stat);
103✔
288
            const isLoop = isForStatement(closed.stat) || isForEachStatement(closed.stat) || isWhileStatement(closed.stat);
103✔
289
            locals.forEach((local, name) => {
103✔
290
                const parentLocal = parent.locals.get(name);
109✔
291
                // if var is an iterator var, flag as partial
292
                if (local.restriction) {
109✔
293
                    local.isUnsafe = true;
41✔
294
                }
295
                // combine attributes / met branches
296
                if (isParentBranched) {
109✔
297
                    if (parentLocal) {
21✔
298
                        local.isUnsafe = parentLocal.isUnsafe || local.isUnsafe;
18✔
299
                        local.metBranches = (parentLocal.metBranches ?? 0) + 1;
18!
300
                    }
301
                } else if (parentLocal && !parentLocal.isUnsafe) {
88✔
302
                    local.isUnsafe = false;
15✔
303
                }
304
                if (parentLocal?.restriction) {
109✔
305
                    local.restriction = parentLocal.restriction;
16✔
306
                }
307
                if (!local.isUsed && isLoop) {
109✔
308
                    // avoid false positive if a local set in a loop isn't used
309
                    const someParentLocal = findLocal(local.name);
8✔
310
                    if (someParentLocal?.isUsed) {
8✔
311
                        local.isUsed = true;
6✔
312
                    }
313
                }
314
                parent.locals.set(name, local);
109✔
315
            });
316
        }
317
    }
318

319
    function visitExpression(expr: Expression, parent: Expression, curr: StatementInfo) {
320
        if (isVariableExpression(expr) && !util.isInTypeExpression(expr)) {
1,233✔
321
            const name = expr.tokens.name.text;
315✔
322
            if (name === 'm') {
315✔
323
                return;
3✔
324
            }
325

326
            const local = findLocal(name);
312✔
327
            if (!local) {
312✔
328
                deferred.push({
91✔
329
                    kind: ValidationKind.UninitializedVar,
330
                    name: name,
331
                    location: expr.location,
332
                    namespace: expr.findAncestor<NamespaceStatement>(isNamespaceStatement)
333
                });
334
                return;
91✔
335
            } else {
336
                local.isUsed = true;
221✔
337
                verifyVarCasing(local, expr.tokens.name);
221✔
338
            }
339

340
            if (local.isUnsafe && !findSafeLocal(name)) {
221✔
341
                if (local.restriction) {
31✔
342
                    diagnostics.push({
2✔
343
                        ...VarTrackingMessages.unsafeIterator(name),
344
                        severity: severity.unsafeIterators,
345
                        location: expr.location
346
                    });
347
                } else if (!isNarrowing(local, expr, parent, curr)) {
29✔
348
                    diagnostics.push({
19✔
349
                        ...VarTrackingMessages.unsafeInitialization(name),
350
                        severity: severity.unsafePathLoop, // should this be severity.assignAllPath?
351
                        location: expr.location
352
                    });
353
                }
354
            }
355
        }
356
    }
357

358
    function isNarrowing(local: VarInfo, expr: Expression, parent: Expression, curr: StatementInfo): boolean {
359
        // Are we inside an `if/elseif` statement condition?
360
        if (!isIfStatement(curr.stat)) {
29✔
361
            return false;
19✔
362
        }
363
        const block = curr.stat.thenBranch;
10✔
364
        // look for a statement testing whether variable is `invalid`,
365
        // like `if x <> invalid` or `else if x = invalid`
366
        if (!isBinaryExpression(parent) || !(isLiteralInvalid(parent.left) || isLiteralInvalid(parent.right))) {
10✔
367
            // maybe the variable was narrowed as part of the condition
368
            // e.g. 2nd condition in: if x <> invalid and x.y = z
369
            return curr.narrows?.some(narrow => narrow.text === local.name);
1!
370
        }
371
        const operator = parent.tokens.operator.kind;
9✔
372
        if (operator !== TokenKind.Equal && operator !== TokenKind.LessGreater) {
9!
UNCOV
373
            return false;
×
374
        }
375
        const narrow: NarrowingInfo = {
9✔
376
            text: local.name,
377
            location: local.location,
378
            type: operator === TokenKind.Equal ? 'invalid' : 'valid',
9✔
379
            block
380
        };
381
        if (!curr.narrows) {
9!
382
            curr.narrows = [];
9✔
383
        }
384
        curr.narrows.push(narrow);
9✔
385
        return true;
9✔
386
    }
387

388
    function finalize(locals: Map<string, VarInfo>) {
389
        locals.forEach(local => {
175✔
390
            if (!local.isUsed && !local.restriction) {
185✔
391
                diagnostics.push({
51✔
392
                    ...VarTrackingMessages.unusedVariable(local.name),
393
                    severity: severity.unusedVariable,
394
                    location: local.location
395
                });
396
            }
397
        });
398

399
        args.forEach(arg => {
175✔
400
            // treat a leading underscore as an intentionally unused parameter
401
            if (!arg.isUsed && !arg.name.startsWith('_')) {
197✔
402
                diagnostics.push({
4✔
403
                    severity: severity.unusedParameter,
404
                    code: VarLintError.UnusedParameter,
405
                    message: `Parameter '${arg.name}' is set but value is never used`,
406
                    location: arg.location
407
                });
408
            }
409
        });
410

411
        args.forEach(arg => {
175✔
412
            // treat a leading underscore as an intentionally unused parameter
413
            if (!arg.isUsed && !arg.name.startsWith('_')) {
197✔
414
                diagnostics.push({
4✔
415
                    severity: severity.unusedParameter,
416
                    code: VarLintError.UnusedParameter,
417
                    message: `Parameter '${arg.name}' is set but value is never used`,
418
                    location: arg.location
419
                });
420
            }
421
        });
422
    }
423

424
    return {
175✔
425
        openBlock: openBlock,
426
        closeBlock: closeBlock,
427
        visitStatement: visitStatement,
428
        visitExpression: visitExpression
429
    };
430
}
431

432
export function runDeferredValidation(
1✔
433
    lintContext: PluginContext,
434
    scope: Scope,
435
    files: BscFile[],
436
    callables: CallableContainerMap
437
) {
438
    const topLevelVars = buildTopLevelVars(scope, lintContext.globals);
65✔
439
    const diagnostics: BsDiagnostic[] = [];
65✔
440
    files.forEach((file) => {
65✔
441
        const deferred = deferredValidation.get(file.srcPath);
73✔
442
        if (deferred) {
73✔
443
            deferredVarLinter(scope, file, callables, topLevelVars, deferred, diagnostics);
69✔
444
        }
445
    });
446
    return diagnostics;
65✔
447
}
448

449
/**
450
 * Get a list of all top level variables available in the scope
451
 */
452
function buildTopLevelVars(scope: Scope, globals: string[]) {
453
    // lookups for namespaces, classes, enums, etc...
454
    // to add them to the topLevel so that they don't get marked as unused.
455
    const toplevel = new Set<string>(globals);
65✔
456

457
    for (const namespace of scope.getAllNamespaceStatements()) {
65✔
458
        toplevel.add(getRootNamespaceName(namespace).toLowerCase()); // keep root of namespace
11✔
459
    }
460
    for (const [, cls] of scope.getClassMap()) {
65✔
461
        toplevel.add(cls.item.tokens.name.text.toLowerCase());
5✔
462
    }
463
    for (const [, enm] of scope.getEnumMap()) {
65✔
464
        toplevel.add(enm.item.name.toLowerCase());
3✔
465
    }
466
    for (const [, cnst] of scope.getConstMap()) {
65✔
467
        toplevel.add(cnst.item.name.toLowerCase());
2✔
468
    }
469
    return toplevel;
65✔
470
}
471

472
function deferredVarLinter(
473
    scope: Scope,
474
    file: BscFile,
475
    callables: CallableContainerMap,
476
    toplevel: Set<string>,
477
    deferred: ValidationInfo[],
478
    diagnostics: BsDiagnostic[]
479
) {
480
    deferred.forEach(({ kind, name, local, location, namespace }) => {
69✔
481
        const key = name?.toLowerCase();
365!
482
        let hasCallable = key ? callables.has(key) || toplevel.has(key) : false;
365!
483
        if (key && !hasCallable && namespace) {
365✔
484
            // check if this could be a callable in the current namespace
485
            const keyUnderNamespace = `${namespace.getName(ParseMode.BrightScript)}_${key}`.toLowerCase();
3✔
486
            hasCallable = callables.has(keyUnderNamespace);
3✔
487
        }
488
        switch (kind) {
365!
489
            case ValidationKind.UninitializedVar:
490
                if (!hasCallable) {
93✔
491
                    diagnostics.push({
10✔
492
                        ...VarTrackingMessages.uninitializedVariable(name, scope.name),
493
                        severity: DiagnosticSeverity.Error,
494
                        location: location
495
                    });
496
                }
497
                // TODO else test case
498
                break;
93✔
499
            case ValidationKind.Assignment:
500
                break;
272✔
501
            case ValidationKind.Unsafe:
UNCOV
502
                break;
×
503
        }
504
    });
505
}
506

507
/**
508
 * Get the leftmost part of the namespace name. (i.e. `alpha` from `alpha.beta.charlie`) by walking
509
 * up the namespace chain until we get to the very topmost namespace. Then grabbing the leftmost token's name.
510
 *
511
 */
512
export function getRootNamespaceName(namespace: NamespaceStatement) {
1✔
513
    const nameParts = namespace.getNameParts();
11✔
514
    return nameParts[0].text;
11✔
515
}
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