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

rokucommunity / bslint / #769

07 Dec 2023 06:32PM CUT coverage: 91.908%. Remained the same
#769

push

TwitchBronBron
0.8.13

775 of 875 branches covered (0.0%)

Branch coverage included in aggregate %.

906 of 954 relevant lines covered (94.97%)

63.17 hits per line

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

92.36
/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
}
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);
128✔
31
}
32

33
export function resetVarContext(file: BscFile) {
1✔
34
    deferredValidation.set(file.pathAbsolute, []);
36✔
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;
128✔
45
    const deferred = getDeferred(file);
128✔
46
    let foundLabelAt = 0;
128✔
47

48
    const args: Map<string, VarInfo> = new Map();
128✔
49
    args.set('m', { name: 'm', range: Range.create(0, 0, 0, 0), isParam: true, isUnsafe: false, isUsed: true });
128✔
50
    fun.parameters.forEach((p) => {
128✔
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 (isMethodStatement(fun.functionStatement)) {
128✔
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) {
245✔
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) {
213!
77
            return;
×
78
        }
79
        const key = name.text.toLowerCase();
213✔
80
        const arg = args.get(key);
213✔
81
        const local = {
213✔
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) {
213✔
91
            verifyVarCasing(arg, name);
3✔
92
            return local;
3✔
93
        }
94

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

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

109
        return local;
210✔
110
    }
111

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

120
        if (parent?.locals?.has(key)) {
245!
121
            return parent.locals.get(key);
117✔
122
        }
123
        for (let i = stack.length - 2; i >= 0; i--) {
128✔
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;
72✔
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();
28✔
137
        const { blocks, stack } = state;
28✔
138
        if (stack.length < 2) {
28✔
139
            return undefined;
19✔
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;
348✔
153
        if (isForStatement(stat)) {
348✔
154
            // for iterator will be declared by the next assignement statement
155
        } else if (isForEachStatement(stat)) {
341✔
156
            // declare `for each` iterator variable
157
            setLocal(block, stat.item, VarRestriction.Iterator);
4✔
158
        } else if (state.parent?.narrows) {
337✔
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;
793✔
187
        if (isAssignmentStatement(stat) && state.parent) {
793✔
188
            // value = stat.value;
189
            setLocal(state.parent, stat.name, isForStatement(state.parent.stat) ? VarRestriction.Iterator : undefined);
196✔
190
        } else if (isCatchStatement(stat) && state.parent) {
597✔
191
            setLocal(curr, stat.exceptionVariable, VarRestriction.CatchedError);
3✔
192
        } else if (isLabelStatement(stat) && !foundLabelAt) {
594✔
193
            foundLabelAt = stat.range.start.line;
1✔
194
        } else if (foundLabelAt && isGotoStatement(stat) && state.parent) {
593✔
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;
350✔
213
        const { parent } = state;
350✔
214
        if (!locals || !parent) {
350✔
215
            if (locals) {
205✔
216
                finalize(locals);
78✔
217
            }
218
            return;
205✔
219
        }
220
        // when closing a branched statement, evaluate vars with partial branches covered
221
        if (branches > 1) {
145✔
222
            locals.forEach((local) => {
64✔
223
                if (local.metBranches !== branches) {
67✔
224
                    local.isUnsafe = true;
55✔
225
                }
226
                local.metBranches = 1;
67✔
227
            });
228
        } else if (isIfStatement(parent.stat)) {
81✔
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)) {
21✔
238
            locals.forEach(local => {
3✔
239
                // drop error variable
240
                if (local.restriction === VarRestriction.CatchedError) {
4✔
241
                    locals.delete(local.name.toLowerCase());
3✔
242
                }
243
            });
244
        }
245
        // move locals to parent
246
        if (!parent.locals) {
145✔
247
            parent.locals = locals;
77✔
248
        } else {
249
            const isParentBranched = isIfStatement(parent.stat) || isTryCatchStatement(parent.stat);
68✔
250
            const isLoop = isForStatement(closed.stat) || isForEachStatement(closed.stat) || isWhileStatement(closed.stat);
68✔
251
            locals.forEach((local, name) => {
68✔
252
                const parentLocal = parent.locals.get(name);
69✔
253
                // if var is an iterator var, flag as partial
254
                if (local.restriction) {
69✔
255
                    local.isUnsafe = true;
8✔
256
                }
257
                // combine attributes / met branches
258
                if (isParentBranched) {
69✔
259
                    if (parentLocal) {
14✔
260
                        local.isUnsafe = parentLocal.isUnsafe || local.isUnsafe;
12✔
261
                        local.metBranches = (parentLocal.metBranches ?? 0) + 1;
12!
262
                    }
263
                } else if (parentLocal && !parentLocal.isUnsafe) {
55✔
264
                    local.isUnsafe = false;
14✔
265
                }
266
                if (parentLocal?.restriction) {
69!
267
                    local.restriction = parentLocal.restriction;
×
268
                }
269
                if (!local.isUsed && isLoop) {
69✔
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);
69✔
277
            });
278
        }
279
    }
280

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

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

302
            if (local.isUnsafe && !findSafeLocal(name)) {
178✔
303
                if (local.restriction) {
26✔
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)) {
24✔
312
                    diagnostics.push({
14✔
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)) {
24✔
327
            return false;
14✔
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 => {
78✔
356
            if (!local.isUsed && !local.restriction) {
145✔
357
                diagnostics.push({
43✔
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 {
128✔
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 topLevelVars = buildTopLevelVars(scope, lintContext.globals);
36✔
383
    const diagnostics: BsDiagnostic[] = [];
36✔
384
    files.forEach((file) => {
36✔
385
        const deferred = deferredValidation.get(file.pathAbsolute);
36✔
386
        if (deferred) {
36!
387
            deferredVarLinter(scope, file, callables, topLevelVars, deferred, diagnostics);
36✔
388
        }
389
    });
390
    return diagnostics;
36✔
391
}
392

393
/**
394
 * Get a list of all top level variables available in the scope
395
 */
396
function buildTopLevelVars(scope: Scope, globals: string[]) {
397
    // lookups for namespaces, classes, enums, etc...
398
    // to add them to the topLevel so that they don't get marked as unused.
399
    const toplevel = new Set<string>(globals);
36✔
400

401
    for (const namespace of scope.getAllNamespaceStatements()) {
36✔
402
        toplevel.add(getRootNamespaceName(namespace).toLowerCase()); // keep root of namespace
6✔
403
    }
404
    for (const [, cls] of scope.getClassMap()) {
36✔
405
        toplevel.add(cls.item.name.text.toLowerCase());
5✔
406
    }
407
    for (const [, enm] of scope.getEnumMap()) {
36✔
408
        toplevel.add(enm.item.name.toLowerCase());
3✔
409
    }
410
    for (const [, cnst] of scope.getConstMap()) {
36✔
411
        toplevel.add(cnst.item.name.toLowerCase());
1✔
412
    }
413
    return toplevel;
36✔
414
}
415

416
function deferredVarLinter(
417
    scope: Scope,
418
    file: BscFile,
419
    callables: CallableContainerMap,
420
    toplevel: Set<string>,
421
    deferred: ValidationInfo[],
422
    diagnostics: BsDiagnostic[]
423
) {
424
    deferred.forEach(({ kind, name, local, range, namespace }) => {
36✔
425
        const key = name?.toLowerCase();
281!
426
        let hasCallable = key ? callables.has(key) || toplevel.has(key) : false;
281!
427
        if (key && !hasCallable && namespace) {
281✔
428
            // check if this could be a callable in the current namespace
429
            const keyUnderNamespace = `${namespace.getName(ParseMode.BrightScript)}_${key}`.toLowerCase();
3✔
430
            hasCallable = callables.has(keyUnderNamespace);
3✔
431
        }
432
        switch (kind) {
281✔
433
            case ValidationKind.UninitializedVar:
281!
434
                if (!hasCallable) {
71✔
435
                    diagnostics.push({
7✔
436
                        severity: DiagnosticSeverity.Error,
437
                        code: VarLintError.UninitializedVar,
438
                        message: `Using uninitialised variable '${name}' when this file is included in scope '${scope.name}'`,
439
                        range: range,
440
                        file: file
441
                    });
442
                }
443
                // TODO else test case
444
                break;
71✔
445
            case ValidationKind.Assignment:
446
                break;
210✔
447
            case ValidationKind.Unsafe:
448
                break;
×
449
        }
450
    });
451
}
452

453
/**
454
 * Get the leftmost part of the namespace name. (i.e. `alpha` from `alpha.beta.charlie`) by walking
455
 * up the namespace chain until we get to the very topmost namespace. Then grabbing the leftmost token's name.
456
 *
457
 */
458
export function getRootNamespaceName(namespace: NamespaceStatement) {
1✔
459
    // there are more concise ways to accomplish this, but this is a hot function so it's been optimized.
460
    while (true) {
6✔
461
        const parent = namespace.parent?.parent as NamespaceStatement;
6!
462
        if (isNamespaceStatement(parent)) {
6!
463
            namespace = parent;
×
464
        } else {
465
            break;
6✔
466
        }
467
    }
468
    const result = util.getDottedGetPath(namespace.nameExpression)[0]?.name?.text;
6!
469
    // const name = namespace.getName(ParseMode.BrighterScript).toLowerCase();
470
    // if (name.includes('imigx')) {
471
    //     console.log([name, result]);
472
    // }
473
    return result;
6✔
474
}
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