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

rokucommunity / bslint / #1354

25 Nov 2025 01:33AM UTC coverage: 91.556% (-0.4%) from 91.927%
#1354

push

GitHub
Merge 8eeb3f88a into 7eec9b785

879 of 997 branches covered (88.16%)

Branch coverage included in aggregate %.

986 of 1040 relevant lines covered (94.81%)

69.87 hits per line

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

92.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, NamespacedVariableNameExpression, ParseMode, util, isMethodStatement, isTryCatchStatement } from 'brighterscript';
1✔
2
import { LintState, StatementInfo, NarrowingInfo, VarInfo, VarRestriction } from '.';
1✔
3
import { PluginContext } from '../../util';
4

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

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

20
interface ValidationInfo {
21
    kind: ValidationKind;
22
    name: string;
23
    local?: VarInfo;
24
    range: Range;
25
    namespace?: NamespacedVariableNameExpression;
26
}
27

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

30
function getDeferred(file: BscFile) {
31
    return deferredValidation.get(file.pathAbsolute);
162✔
32
}
33

34
export function resetVarContext(file: BscFile) {
1✔
35
    deferredValidation.set(file.pathAbsolute, []);
44✔
36
}
37

38
export function createVarLinter(
1✔
39
    lintContext: PluginContext,
40
    file: BscFile,
41
    fun: FunctionExpression,
42
    state: LintState,
43
    diagnostics: BsDiagnostic[]
44
) {
45
    const { severity } = lintContext;
162✔
46
    const deferred = getDeferred(file);
162✔
47
    let foundLabelAt = 0;
162✔
48

49
    const args: Map<string, VarInfo> = new Map();
162✔
50
    args.set('m', { name: 'm', range: Range.create(0, 0, 0, 0), isParam: true, isUnsafe: false, isUsed: true });
162✔
51
    fun.parameters.forEach((p) => {
162✔
52
        const name = p.name.text;
53✔
53
        args.set(name.toLowerCase(), { name: name, range: p.name.range, isParam: true, isUnsafe: false, isUsed: false });
53✔
54
    });
55

56
    if (isMethodStatement(fun.functionStatement)) {
162✔
57
        args.set('super', { name: 'super', range: null, isParam: true, isUnsafe: false, isUsed: true });
8✔
58
    }
59

60
    function verifyVarCasing(curr: VarInfo, name: { text: string; range: Range }) {
61
        if (curr && curr.name !== name.text) {
285✔
62
            diagnostics.push({
12✔
63
                severity: severity.caseSensitivity,
64
                code: VarLintError.CaseMismatch,
65
                message: `Variable '${name.text}' was previously set with a different casing as '${curr.name}'`,
66
                range: name.range,
67
                file: file,
68
                data: {
69
                    name: curr.name,
70
                    range: name.range
71
                }
72
            });
73
        }
74
    }
75

76
    function setLocal(parent: StatementInfo, name: { text: string; range: Range }, restriction?: VarRestriction): VarInfo {
77
        if (!name) {
232!
78
            return;
×
79
        }
80
        const key = name.text.toLowerCase();
232✔
81
        const arg = args.get(key);
232✔
82
        const local = {
232✔
83
            name: name.text,
84
            range: name.range,
85
            parent: parent,
86
            restriction: restriction,
87
            metBranches: 1,
88
            isUnsafe: false,
89
            isUsed: false
90
        };
91
        if (arg) {
232✔
92
            verifyVarCasing(arg, name);
3✔
93
            arg.isUsed = true;
3✔
94
            return local;
3✔
95
        }
96

97
        if (!parent.locals) {
229✔
98
            parent.locals = new Map();
165✔
99
        } else {
100
            verifyVarCasing(parent.locals.get(key), name);
64✔
101
        }
102
        parent.locals.set(key, local);
229✔
103

104
        deferred.push({
229✔
105
            kind: ValidationKind.Assignment,
106
            name: name.text,
107
            local: local,
108
            range: name.range
109
        });
110

111
        return local;
229✔
112
    }
113

114
    function findLocal(name: string): VarInfo | undefined {
115
        const key = name.toLowerCase();
305✔
116
        const arg = args.get(key);
305✔
117
        if (arg) {
305✔
118
            return arg;
27✔
119
        }
120
        const { parent, blocks, stack } = state;
278✔
121

122
        if (parent?.locals?.has(key)) {
278!
123
            return parent.locals.get(key);
132✔
124
        }
125
        for (let i = stack.length - 2; i >= 0; i--) {
146✔
126
            const block = blocks.get(stack[i]);
132✔
127
            const local = block?.locals?.get(key);
132!
128
            if (local) {
132✔
129
                return local;
61✔
130
            }
131
        }
132
        return undefined;
85✔
133
    }
134

135
    // A local was found but it is considered unsafe (e.g. set in an if branch)
136
    // Found out whether a parent has this variable set safely
137
    function findSafeLocal(name: string): VarInfo | undefined {
138
        const key = name.toLowerCase();
28✔
139
        const { blocks, stack } = state;
28✔
140
        if (stack.length < 2) {
28✔
141
            return undefined;
19✔
142
        }
143
        for (let i = stack.length - 2; i >= 0; i--) {
9✔
144
            const block = blocks.get(stack[i]);
18✔
145
            const local = block?.locals?.get(key);
18!
146
            // if partial, look up higher in the scope for a non-partial
147
            if (local && !local.isUnsafe) {
18✔
148
                return local;
2✔
149
            }
150
        }
151
    }
152

153
    function openBlock(block: StatementInfo) {
154
        const { stat } = block;
415✔
155
        if (isForStatement(stat)) {
415✔
156
            // for iterator will be declared by the next assignment statement
157
        } else if (isForEachStatement(stat)) {
406✔
158
            // declare `for each` iterator variable
159
            setLocal(block, stat.item, VarRestriction.Iterator);
4✔
160
        } else if (state.parent?.narrows) {
402✔
161
            narrowBlock(block);
10✔
162
        }
163
    }
164

165
    function narrowBlock(block: StatementInfo) {
166
        const { parent } = state;
10✔
167
        const { stat } = block;
10✔
168

169
        if (isIfStatement(stat) && isIfStatement(parent.stat)) {
10!
170
            block.narrows = parent?.narrows;
×
171
            return;
×
172
        }
173

174
        parent?.narrows?.forEach(narrow => {
10!
175
            if (narrow.block === stat) {
10✔
176
                setLocal(block, narrow).narrowed = narrow;
9✔
177
            } else {
178
                // opposite narrowing for other branches
179
                setLocal(block, narrow).narrowed = {
1✔
180
                    ...narrow,
181
                    type: narrow.type === 'invalid' ? 'valid' : 'invalid'
1!
182
                };
183
            }
184
        });
185
    }
186

187
    function visitStatement(curr: StatementInfo) {
188
        const { stat } = curr;
941✔
189
        if (isAssignmentStatement(stat) && state.parent) {
941✔
190
            // value = stat.value;
191
            setLocal(state.parent, stat.name, isForStatement(state.parent.stat) ? VarRestriction.Iterator : undefined);
215✔
192
        } else if (isCatchStatement(stat) && state.parent) {
726✔
193
            setLocal(curr, stat.exceptionVariable, VarRestriction.CatchedError);
3✔
194
        } else if (isLabelStatement(stat) && !foundLabelAt) {
723✔
195
            foundLabelAt = stat.range.start.line;
1✔
196
        } else if (foundLabelAt && isGotoStatement(stat) && state.parent) {
722✔
197
            // To avoid false positives when finding a goto statement,
198
            // very generously mark as used all unused variables after 1st found label line.
199
            // This isn't accurate but tracking usage across goto jumps is tricky
200
            const { stack, blocks } = state;
1✔
201
            const labelLine = foundLabelAt;
1✔
202
            for (let i = state.stack.length - 1; i >= 0; i--) {
1✔
203
                const block = blocks.get(stack[i]);
3✔
204
                block?.locals?.forEach(local => {
3!
205
                    if (local.range?.start.line > labelLine) {
5!
206
                        local.isUsed = true;
4✔
207
                    }
208
                });
209
            }
210
        }
211
    }
212

213
    function closeBlock(closed: StatementInfo) {
214
        const { locals, branches, returns } = closed;
422✔
215
        const { parent } = state;
422✔
216
        if (!locals || !parent) {
422✔
217
            // Finalize when there's no parent, i.e. end of function
218
            if (!parent) {
274✔
219
                finalize(locals);
162✔
220
            }
221
            return;
274✔
222
        }
223
        // when closing a branched statement, evaluate vars with partial branches covered
224
        if (branches > 1) {
148✔
225
            locals.forEach((local) => {
66✔
226
                if (local.metBranches !== branches) {
69✔
227
                    local.isUnsafe = true;
57✔
228
                }
229
                local.metBranches = 1;
69✔
230
            });
231
        } else if (isIfStatement(parent.stat)) {
82✔
232
            locals.forEach(local => {
60✔
233
                // keep narrowed vars if we `return` the invalid branch
234
                if (local.narrowed) {
62✔
235
                    if (!returns || local.narrowed.type === 'valid') {
10✔
236
                        locals.delete(local.name.toLowerCase());
7✔
237
                    }
238
                }
239
            });
240
        } else if (isCatchStatement(closed.stat)) {
22✔
241
            locals.forEach(local => {
3✔
242
                // drop error variable
243
                if (local.restriction === VarRestriction.CatchedError) {
4✔
244
                    locals.delete(local.name.toLowerCase());
3✔
245
                }
246
            });
247
        }
248
        // move locals to parent
249
        if (!parent.locals) {
148✔
250
            parent.locals = locals;
79✔
251
        } else {
252
            const isParentBranched = isIfStatement(parent.stat) || isTryCatchStatement(parent.stat);
69✔
253
            const isLoop = isForStatement(closed.stat) || isForEachStatement(closed.stat) || isWhileStatement(closed.stat);
69✔
254
            locals.forEach((local, name) => {
69✔
255
                const parentLocal = parent.locals.get(name);
70✔
256
                // if var is an iterator var, flag as partial
257
                if (local.restriction) {
70✔
258
                    local.isUnsafe = true;
9✔
259
                }
260
                // combine attributes / met branches
261
                if (isParentBranched) {
70✔
262
                    if (parentLocal) {
14✔
263
                        local.isUnsafe = parentLocal.isUnsafe || local.isUnsafe;
12✔
264
                        local.metBranches = (parentLocal.metBranches ?? 0) + 1;
12!
265
                    }
266
                } else if (parentLocal && !parentLocal.isUnsafe) {
56✔
267
                    local.isUnsafe = false;
15✔
268
                }
269
                if (parentLocal?.restriction) {
70✔
270
                    local.restriction = parentLocal.restriction;
1✔
271
                }
272
                if (!local.isUsed && isLoop) {
70✔
273
                    // avoid false positive if a local set in a loop isn't used
274
                    const someParentLocal = findLocal(local.name);
3✔
275
                    if (someParentLocal?.isUsed) {
3✔
276
                        local.isUsed = true;
1✔
277
                    }
278
                }
279
                parent.locals.set(name, local);
70✔
280
            });
281
        }
282
    }
283

284
    function visitExpression(expr: Expression, parent: Expression, curr: StatementInfo) {
285
        if (isVariableExpression(expr)) {
1,033✔
286
            const name = expr.name.text;
305✔
287
            if (name === 'm') {
305✔
288
                return;
3✔
289
            }
290

291
            const local = findLocal(name);
302✔
292
            if (!local) {
302✔
293
                deferred.push({
84✔
294
                    kind: ValidationKind.UninitializedVar,
295
                    name: name,
296
                    range: expr.range,
297
                    namespace: expr.findAncestor<NamespaceStatement>(isNamespaceStatement)?.nameExpression
252✔
298
                });
299
                return;
84✔
300
            } else {
301
                local.isUsed = true;
218✔
302
                verifyVarCasing(local, expr.name);
218✔
303
            }
304

305
            if (local.isUnsafe && !findSafeLocal(name)) {
218✔
306
                if (local.restriction) {
26✔
307
                    diagnostics.push({
2✔
308
                        severity: severity.unsafeIterators,
309
                        code: VarLintError.UnsafeIteratorVar,
310
                        message: `Using iterator variable '${name}' outside loop`,
311
                        range: expr.range,
312
                        file: file
313
                    });
314
                } else if (!isNarrowing(local, expr, parent, curr)) {
24✔
315
                    diagnostics.push({
14✔
316
                        severity: severity.unsafePathLoop,
317
                        code: VarLintError.UnsafeInitialization,
318
                        message: `Not all the code paths assign '${name}'`,
319
                        range: expr.range,
320
                        file: file
321
                    });
322
                }
323
            }
324
        }
325
    }
326

327
    function isNarrowing(local: VarInfo, expr: Expression, parent: Expression, curr: StatementInfo): boolean {
328
        // Are we inside an `if/elseif` statement condition?
329
        if (!isIfStatement(curr.stat)) {
24✔
330
            return false;
14✔
331
        }
332
        const block = curr.stat.thenBranch;
10✔
333
        // look for a statement testing whether variable is `invalid`,
334
        // like `if x <> invalid` or `else if x = invalid`
335
        if (!isBinaryExpression(parent) || !(isLiteralInvalid(parent.left) || isLiteralInvalid(parent.right))) {
10✔
336
            // maybe the variable was narrowed as part of the condition
337
            // e.g. 2nd condition in: if x <> invalid and x.y = z
338
            return curr.narrows?.some(narrow => narrow.text === local.name);
1!
339
        }
340
        const operator = parent.operator.kind;
9✔
341
        if (operator !== TokenKind.Equal && operator !== TokenKind.LessGreater) {
9!
342
            return false;
×
343
        }
344
        const narrow: NarrowingInfo = {
9✔
345
            text: local.name,
346
            range: local.range,
347
            type: operator === TokenKind.Equal ? 'invalid' : 'valid',
9✔
348
            block
349
        };
350
        if (!curr.narrows) {
9!
351
            curr.narrows = [];
9✔
352
        }
353
        curr.narrows.push(narrow);
9✔
354
        return true;
9✔
355
    }
356

357
    function finalize(locals?: Map<string, VarInfo>) {
358
        locals?.forEach(local => {
162✔
359
            if (!local.isUsed && !local.restriction) {
163✔
360
                diagnostics.push({
45✔
361
                    severity: severity.unusedVariable,
362
                    code: VarLintError.UnusedVariable,
363
                    message: `Variable '${local.name}' is set but value is never used`,
364
                    range: local.range,
365
                    file: file
366
                });
367
            }
368
        });
369

370
        args?.forEach(arg => {
162!
371
            // treat a leading underscore as an intentionally unused parameter
372
            if (!arg.isUsed && !arg.name.startsWith('_')) {
223✔
373
                diagnostics.push({
15✔
374
                    severity: severity.unusedParameter,
375
                    code: VarLintError.UnusedParameter,
376
                    message: `Parameter '${arg.name}' is set but value is never used`,
377
                    range: arg.range,
378
                    file: file,
379
                    data: {
380
                        name: arg.name,
381
                        range: arg.range,
382
                        isExperimental: true
383
                    }
384
                });
385
            }
386
        });
387
    }
388

389
    return {
162✔
390
        openBlock: openBlock,
391
        closeBlock: closeBlock,
392
        visitStatement: visitStatement,
393
        visitExpression: visitExpression
394
    };
395
}
396

397
export function runDeferredValidation(
1✔
398
    lintContext: PluginContext,
399
    scope: Scope,
400
    files: BscFile[],
401
    callables: CallableContainerMap
402
) {
403
    const topLevelVars = buildTopLevelVars(scope, lintContext.globals);
44✔
404
    const diagnostics: BsDiagnostic[] = [];
44✔
405
    files.forEach((file) => {
44✔
406
        const deferred = deferredValidation.get(file.pathAbsolute);
44✔
407
        if (deferred) {
44!
408
            deferredVarLinter(scope, file, callables, topLevelVars, deferred, diagnostics);
44✔
409
        }
410
    });
411
    return diagnostics;
44✔
412
}
413

414
/**
415
 * Get a list of all top level variables available in the scope
416
 */
417
function buildTopLevelVars(scope: Scope, globals: string[]) {
418
    // lookups for namespaces, classes, enums, etc...
419
    // to add them to the topLevel so that they don't get marked as unused.
420
    const toplevel = new Set<string>(globals);
44✔
421

422
    for (const namespace of scope.getAllNamespaceStatements()) {
44✔
423
        toplevel.add(getRootNamespaceName(namespace).toLowerCase()); // keep root of namespace
6✔
424
    }
425
    for (const [, cls] of scope.getClassMap()) {
44✔
426
        toplevel.add(cls.item.name.text.toLowerCase());
5✔
427
    }
428
    for (const [, enm] of scope.getEnumMap()) {
44✔
429
        toplevel.add(enm.item.name.toLowerCase());
3✔
430
    }
431
    for (const [, cnst] of scope.getConstMap()) {
44✔
432
        toplevel.add(cnst.item.name.toLowerCase());
1✔
433
    }
434
    return toplevel;
44✔
435
}
436

437
function deferredVarLinter(
438
    scope: Scope,
439
    file: BscFile,
440
    callables: CallableContainerMap,
441
    toplevel: Set<string>,
442
    deferred: ValidationInfo[],
443
    diagnostics: BsDiagnostic[]
444
) {
445
    deferred.forEach(({ kind, name, local, range, namespace }) => {
44✔
446
        const key = name?.toLowerCase();
313!
447
        let hasCallable = key ? callables.has(key) || toplevel.has(key) : false;
313!
448
        if (key && !hasCallable && namespace) {
313✔
449
            // check if this could be a callable in the current namespace
450
            const keyUnderNamespace = `${namespace.getName(ParseMode.BrightScript)}_${key}`.toLowerCase();
3✔
451
            hasCallable = callables.has(keyUnderNamespace);
3✔
452
        }
453
        switch (kind) {
313✔
454
            case ValidationKind.UninitializedVar:
313!
455
                if (!hasCallable) {
84✔
456
                    diagnostics.push({
7✔
457
                        severity: DiagnosticSeverity.Error,
458
                        code: VarLintError.UninitializedVar,
459
                        message: `Using uninitialised variable '${name}' when this file is included in scope '${scope.name}'`,
460
                        range: range,
461
                        file: file
462
                    });
463
                }
464
                // TODO else test case
465
                break;
84✔
466
            case ValidationKind.Assignment:
467
                break;
229✔
468
            case ValidationKind.Unsafe:
469
                break;
×
470
        }
471
    });
472
}
473

474
/**
475
 * Get the leftmost part of the namespace name. (i.e. `alpha` from `alpha.beta.charlie`) by walking
476
 * up the namespace chain until we get to the very topmost namespace. Then grabbing the leftmost token's name.
477
 *
478
 */
479
export function getRootNamespaceName(namespace: NamespaceStatement) {
1✔
480
    // there are more concise ways to accomplish this, but this is a hot function so it's been optimized.
481
    while (true) {
6✔
482
        const parent = namespace.parent?.parent as NamespaceStatement;
6!
483
        if (isNamespaceStatement(parent)) {
6!
484
            namespace = parent;
×
485
        } else {
486
            break;
6✔
487
        }
488
    }
489
    const result = util.getDottedGetPath(namespace.nameExpression)[0]?.name?.text;
6!
490
    // const name = namespace.getName(ParseMode.BrighterScript).toLowerCase();
491
    // if (name.includes('imigx')) {
492
    //     console.log([name, result]);
493
    // }
494
    return result;
6✔
495
}
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