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

rokucommunity / bslint / #592

pending completion
#592

push

TwitchBronBron
0.7.6

672 of 767 branches covered (87.61%)

Branch coverage included in aggregate %.

800 of 843 relevant lines covered (94.9%)

56.53 hits per line

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

92.66
/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, isClassMethodStatement, isBrsFile, isCatchStatement, isLabelStatement, isGotoStatement, NamespacedVariableNameExpression, ParseMode } 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
}
12

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

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

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

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

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

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

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

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

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

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

95
        if (!parent.locals) {
155✔
96
            parent.locals = new Map();
133✔
97
        } else {
98
            verifyVarCasing(parent.locals.get(key), name);
22✔
99
        }
100
        parent.locals.set(key, local);
155✔
101

102
        deferred.push({
155✔
103
            kind: ValidationKind.Assignment,
104
            name: name.text,
105
            local: local,
106
            range: name.range
107
        });
108

109
        return local;
155✔
110
    }
111

112
    function findLocal(name: string): VarInfo | undefined {
113
        const key = name.toLowerCase();
249✔
114
        const arg = args.get(key);
249✔
115
        if (arg) {
249✔
116
            return arg;
7✔
117
        }
118
        const { parent, blocks, stack } = state;
242✔
119

120
        if (parent?.locals?.has(key)) {
242!
121
            return parent.locals.get(key);
115✔
122
        }
123
        for (let i = stack.length - 2; i >= 0; i--) {
127✔
124
            const block = blocks.get(stack[i]);
102✔
125
            const local = block?.locals?.get(key);
102!
126
            if (local) {
102✔
127
                return local;
56✔
128
            }
129
        }
130
        return undefined;
71✔
131
    }
132

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

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

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

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

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

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

211
    function closeBlock(closed: StatementInfo) {
212
        const { locals, branches, returns } = closed;
328✔
213
        const { parent } = state;
328✔
214
        if (!locals || !parent) {
328✔
215
            if (locals) {
187✔
216
                finalize(locals);
67✔
217
            }
218
            return;
187✔
219
        }
220
        // when closing a branched statement, evaluate vars with partial branches covered
221
        if (branches > 1) {
141✔
222
            locals.forEach((local) => {
63✔
223
                if (local.metBranches !== branches) {
65✔
224
                    local.isUnsafe = true;
54✔
225
                }
226
                local.metBranches = 1;
65✔
227
            });
228
        } else if (isIfStatement(parent.stat)) {
78✔
229
            locals.forEach(local => {
60✔
230
                // keep narrowed vars if we `return` the invalid branch
231
                if (local.narrowed) {
62✔
232
                    if (!returns || local.narrowed.type === 'valid') {
10✔
233
                        locals.delete(local.name.toLowerCase());
7✔
234
                    }
235
                }
236
            });
237
        } else if (isCatchStatement(closed.stat)) {
18✔
238
            locals.forEach(local => {
2✔
239
                // drop error variable
240
                if (local.restriction === VarRestriction.CatchedError) {
2!
241
                    locals.delete(local.name.toLowerCase());
2✔
242
                }
243
            });
244
        }
245
        // move locals to parent
246
        if (!parent.locals) {
141✔
247
            parent.locals = locals;
75✔
248
        } else {
249
            const isParentIf = isIfStatement(parent.stat);
66✔
250
            const isLoop = isForStatement(closed.stat) || isForEachStatement(closed.stat) || isWhileStatement(closed.stat);
66✔
251
            locals.forEach((local, name) => {
66✔
252
                const parentLocal = parent.locals.get(name);
67✔
253
                // if var is an iterator var, flag as partial
254
                if (local.restriction) {
67✔
255
                    local.isUnsafe = true;
8✔
256
                }
257
                // if a parent var isn't partial then the var stays non-partial
258
                if (isParentIf) {
67✔
259
                    if (parentLocal) {
13✔
260
                        local.isUnsafe = parentLocal.isUnsafe || local.isUnsafe;
11✔
261
                        local.metBranches = (parentLocal.metBranches ?? 0) + 1;
11!
262
                    }
263
                } else if (parentLocal && !parentLocal.isUnsafe) {
54✔
264
                    local.isUnsafe = false;
14✔
265
                }
266
                if (parentLocal?.restriction) {
67!
267
                    local.restriction = parentLocal.restriction;
×
268
                }
269
                if (!local.isUsed && isLoop) {
67✔
270
                    // avoid false positive if a local set in a loop isn't used
271
                    const someParentLocal = findLocal(local.name);
3✔
272
                    if (someParentLocal?.isUsed) {
3✔
273
                        local.isUsed = true;
1✔
274
                    }
275
                }
276
                parent.locals.set(name, local);
67✔
277
            });
278
        }
279
    }
280

281
    function visitExpression(expr: Expression, parent: Expression, curr: StatementInfo) {
282
        if (isVariableExpression(expr)) {
756✔
283
            const name = expr.name.text;
249✔
284
            if (name === 'm') {
249✔
285
                return;
3✔
286
            }
287

288
            const local = findLocal(name);
246✔
289
            if (!local) {
246✔
290
                deferred.push({
70✔
291
                    kind: ValidationKind.UninitializedVar,
292
                    name: name,
293
                    range: expr.range,
294
                    namespace: expr.findAncestor<NamespaceStatement>(isNamespaceStatement)?.nameExpression
210✔
295
                });
296
                return;
70✔
297
            } else {
298
                local.isUsed = true;
176✔
299
                verifyVarCasing(local, expr.name);
176✔
300
            }
301

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

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

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

368
    return {
110✔
369
        openBlock: openBlock,
370
        closeBlock: closeBlock,
371
        visitStatement: visitStatement,
372
        visitExpression: visitExpression
373
    };
374
}
375

376
export function runDeferredValidation(
1✔
377
    lintContext: PluginContext,
378
    scope: Scope,
379
    files: BscFile[],
380
    callables: CallableContainerMap
381
) {
382
    const globals = lintContext.globals;
18✔
383

384
    const diagnostics: BsDiagnostic[] = [];
18✔
385
    files.forEach((file) => {
18✔
386
        const deferred = deferredValidation.get(file.pathAbsolute);
18✔
387
        if (deferred) {
18!
388
            deferredVarLinter(scope, file, callables, globals, deferred, diagnostics);
18✔
389
        }
390
    });
391
    return diagnostics;
18✔
392
}
393

394
function deferredVarLinter(
395
    scope: Scope,
396
    file: BscFile,
397
    callables: CallableContainerMap,
398
    globals: string[],
399
    deferred: ValidationInfo[],
400
    diagnostics: BsDiagnostic[]
401
) {
402
    // lookups for namespaces, classes, and enums
403
    // to add them to the topLevel so that they don't get marked as unused.
404
    const toplevel = new Set<string>(globals);
18✔
405
    scope.getAllNamespaceStatements().forEach(ns => {
18✔
406
        toplevel.add(ns.name.toLowerCase().split('.')[0]); // keep root of namespace
6✔
407
    });
408
    scope.getClassMap().forEach(cls => {
18✔
409
        toplevel.add(cls.item.name.text.toLowerCase());
5✔
410
    });
411
    scope.getEnumMap().forEach(enm => {
18✔
412
        toplevel.add(enm.item.name.toLowerCase());
3✔
413
    });
414
    if (isBrsFile(file)) {
18!
415
        file.parser.references.classStatements.forEach(cls => {
18✔
416
            toplevel.add(cls.name.text.toLowerCase());
5✔
417
        });
418
    }
419

420
    deferred.forEach(({ kind, name, local, range, namespace }) => {
18✔
421
        const key = name?.toLowerCase();
225!
422
        let hasCallable = key ? callables.has(key) || toplevel.has(key) : false;
225!
423
        if (key && !hasCallable && namespace) {
225✔
424
            // check if this could be a callable in the current namespace
425
            const keyUnderNamespace = `${namespace.getName(ParseMode.BrightScript)}_${key}`.toLowerCase();
3✔
426
            hasCallable = callables.has(keyUnderNamespace);
3✔
427
        }
428
        switch (kind) {
225✔
429
            case ValidationKind.UninitializedVar:
225!
430
                if (!hasCallable) {
70✔
431
                    diagnostics.push({
7✔
432
                        severity: DiagnosticSeverity.Error,
433
                        code: VarLintError.UninitializedVar,
434
                        message: `Using uninitialised variable '${name}' when this file is included in scope '${scope.name}'`,
435
                        range: range,
436
                        file: file
437
                    });
438
                }
439
                // TODO else test case
440
                break;
70✔
441
            case ValidationKind.Assignment:
442
                break;
155✔
443
            case ValidationKind.Unsafe:
444
                break;
×
445
        }
446
    });
447
}
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