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

rokucommunity / bslint / #1457

25 Mar 2026 07:44PM UTC coverage: 92.319% (+0.4%) from 91.889%
#1457

push

web-flow
Add support for "fix all" to most code actions (#178)

860 of 974 branches covered (88.3%)

Branch coverage included in aggregate %.

13 of 14 new or added lines in 2 files covered. (92.86%)

991 of 1031 relevant lines covered (96.12%)

65.82 hits per line

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

94.25
/src/plugins/codeStyle/index.ts
1
import {
1✔
2
    BscFile,
3
    XmlFile,
4
    BsDiagnostic,
5
    createVisitor,
6
    FunctionExpression,
7
    isBrsFile,
8
    isXmlFile,
9
    isGroupingExpression,
10
    TokenKind,
11
    WalkMode,
12
    CancellationTokenSource,
13
    DiagnosticSeverity,
14
    OnGetCodeActionsEvent,
15
    isCommentStatement,
16
    AALiteralExpression,
17
    AAMemberExpression,
18
    BrsFile,
19
    isVariableExpression,
20
    isLiteralExpression,
21
    CallExpression,
22
    isForEachStatement,
23
    isForStatement,
24
    isWhileStatement,
25
    isIfStatement,
26
    isFunctionExpression,
27
    AstNode,
28
    Expression
29
} from 'brighterscript';
30
import { RuleAAComma } from '../..';
31
import { addFixAllToEvent, addFixesToEvent } from '../../textEdit';
1✔
32
import { PluginContext } from '../../util';
33
import { createColorValidator } from '../../createColorValidator';
1✔
34
import { messages } from './diagnosticMessages';
1✔
35
import { extractFixes, getFixes } from './styleFixes';
1✔
36

37
export default class CodeStyle {
1✔
38
    name: 'codeStyle';
39

40
    constructor(private lintContext: PluginContext) {
151✔
41
    }
42

43
    onGetCodeActions(event: OnGetCodeActionsEvent) {
44
        const addFixes = addFixesToEvent(event);
4✔
45
        extractFixes(addFixes, event.diagnostics);
4✔
46

47
        // For each fixable code touched by the cursor, offer "fix all" if there are
48
        // multiple occurrences of that code across the whole file
49
        const handledCodes = new Set<string | number>();
4✔
50
        for (const diagnostic of event.diagnostics) {
4✔
51
            if (handledCodes.has(diagnostic.code) || !getFixes(diagnostic)) {
4!
NEW
52
                continue;
×
53
            }
54
            const allInFile = event.program.getDiagnostics()
4✔
55
                .filter(x => x.file === event.file && x.code === diagnostic.code);
15✔
56
            if (allInFile.length > 1) {
4✔
57
                handledCodes.add(diagnostic.code);
3✔
58
                addFixAllToEvent(event, allInFile.map(d => getFixes(d)).filter(Boolean));
9✔
59
            }
60
        }
61
    }
62

63
    validateXMLFile(file: XmlFile) {
64
        const diagnostics: Omit<BsDiagnostic, 'file'>[] = [];
2✔
65
        const { noArrayComponentFieldType, noAssocarrayComponentFieldType } = this.lintContext.severity;
2✔
66

67
        const validateArrayComponentFieldType = noArrayComponentFieldType !== DiagnosticSeverity.Hint;
2✔
68
        const validateAssocarrayComponentFieldType = noAssocarrayComponentFieldType !== DiagnosticSeverity.Hint;
2✔
69

70
        for (const field of file.parser?.ast?.component?.api?.fields ?? []) {
2!
71
            const { tag, attributes } = field;
10✔
72
            if (tag.text === 'field') {
10!
73
                const typeAttribute = attributes.find(({ key }) => key.text === 'type');
20✔
74

75
                const typeValue = typeAttribute?.value.text?.toLowerCase();
10!
76
                if (typeValue === 'array' && validateArrayComponentFieldType) {
10✔
77
                    diagnostics.push(
2✔
78
                        messages.noArrayFieldType(
79
                            typeAttribute.value.range,
80
                            noArrayComponentFieldType
81
                        )
82
                    );
83
                } else if (typeValue === 'assocarray' && validateAssocarrayComponentFieldType) {
8✔
84
                    diagnostics.push(
2✔
85
                        messages.noAssocarrayFieldType(
86
                            typeAttribute.value.range,
87
                            noAssocarrayComponentFieldType
88
                        )
89
                    );
90
                }
91
            }
92
        }
93

94
        return diagnostics;
2✔
95
    }
96

97
    validateBrsFile(file: BrsFile) {
98
        const diagnostics: (Omit<BsDiagnostic, 'file'>)[] = [];
65✔
99
        const { severity } = this.lintContext;
65✔
100
        const { inlineIfStyle, blockIfStyle, conditionStyle, noPrint, noTodo, noStop, aaCommaStyle, eolLast, colorFormat, noRegexDuplicates } = severity;
65✔
101
        const validatePrint = noPrint !== DiagnosticSeverity.Hint;
65✔
102
        const validateTodo = noTodo !== DiagnosticSeverity.Hint;
65✔
103
        const validateNoStop = noStop !== DiagnosticSeverity.Hint;
65✔
104
        const validateNoRegexDuplicates = noRegexDuplicates !== DiagnosticSeverity.Hint;
65✔
105
        const validateInlineIf = inlineIfStyle !== 'off';
65✔
106
        const validateColorFormat = (colorFormat === 'hash-hex' || colorFormat === 'quoted-numeric-hex' || colorFormat === 'never');
65✔
107
        const disallowInlineIf = inlineIfStyle === 'never';
65✔
108
        const requireInlineIfThen = inlineIfStyle === 'then';
65✔
109
        const validateBlockIf = blockIfStyle !== 'off';
65✔
110
        const requireBlockIfThen = blockIfStyle === 'then';
65✔
111
        const validateCondition = conditionStyle !== 'off';
65✔
112
        const requireConditionGroup = conditionStyle === 'group';
65✔
113
        const validateAAStyle = aaCommaStyle !== 'off';
65✔
114
        const walkExpressions = validateAAStyle || validateColorFormat;
65✔
115
        const validateEolLast = eolLast !== 'off';
65✔
116
        const disallowEolLast = eolLast === 'never';
65✔
117
        const validateColorStyle = validateColorFormat ? createColorValidator(severity) : undefined;
65✔
118

119
        // Check if the file is empty by going backwards from the last token,
120
        // meaning there are tokens other than `Eof` and `Newline`.
121
        const { tokens } = file.parser;
65✔
122
        let isFileEmpty = true;
65✔
123
        for (let i = tokens.length - 1; i >= 0; i--) {
65✔
124
            if (tokens[i].kind !== TokenKind.Eof &&
183✔
125
                tokens[i].kind !== TokenKind.Newline) {
126
                isFileEmpty = false;
64✔
127
                break;
64✔
128
            }
129
        }
130

131
        // Validate `eol-last` on non-empty files
132
        if (validateEolLast && !isFileEmpty) {
65✔
133
            const penultimateToken = tokens[tokens.length - 2];
39✔
134
            if (disallowEolLast) {
39✔
135
                if (penultimateToken?.kind === TokenKind.Newline) {
2!
136
                    diagnostics.push(messages.removeEolLast(penultimateToken.range));
2✔
137
                }
138
            } else if (penultimateToken?.kind !== TokenKind.Newline) {
37!
139
                // Set the preferredEol as the last newline.
140
                // The fix function will handle the case where preferredEol is undefined.
141
                // This could happen in valid single line files, like:
142
                // `sub foo() end sub\EOF`
143
                let preferredEol;
144
                for (let i = tokens.length - 1; i >= 0; i--) {
3✔
145
                    if (tokens[i].kind === TokenKind.Newline) {
38✔
146
                        preferredEol = tokens[i].text;
4✔
147
                    }
148
                }
149

150
                diagnostics.push(
3✔
151
                    messages.addEolLast(
152
                        penultimateToken.range,
153
                        preferredEol
154
                    )
155
                );
156
            }
157
        }
158

159
        if (validateNoRegexDuplicates) {
65✔
160
            this.validateRegex(file, diagnostics, noRegexDuplicates);
3✔
161
        }
162

163
        file.ast.walk(createVisitor({
65✔
164
            IfStatement: s => {
165
                const hasThenToken = !!s.tokens.then;
76✔
166
                if (!s.isInline && validateBlockIf) {
76✔
167
                    if (hasThenToken !== requireBlockIfThen) {
14✔
168
                        diagnostics.push(requireBlockIfThen
7✔
169
                            ? messages.addBlockIfThenKeyword(s)
7✔
170
                            : messages.removeBlockIfThenKeyword(s)
171
                        );
172
                    }
173
                } else if (s.isInline && validateInlineIf) {
62✔
174
                    if (disallowInlineIf) {
14✔
175
                        diagnostics.push(messages.inlineIfNotAllowed(s.range));
2✔
176
                    } else if (hasThenToken !== requireInlineIfThen) {
12✔
177
                        diagnostics.push(requireInlineIfThen
6✔
178
                            ? messages.addInlineIfThenKeyword(s)
6✔
179
                            : messages.removeInlineIfThenKeyword(s)
180
                        );
181
                    }
182
                }
183

184
                if (validateCondition) {
76✔
185
                    if (isGroupingExpression(s.condition) !== requireConditionGroup) {
22✔
186
                        diagnostics.push(requireConditionGroup
11✔
187
                            ? messages.addParenthesisAroundCondition(s)
11✔
188
                            : messages.removeParenthesisAroundCondition(s)
189
                        );
190
                    }
191
                }
192
            },
193
            WhileStatement: s => {
194
                if (validateCondition) {
12✔
195
                    if (isGroupingExpression(s.condition) !== requireConditionGroup) {
6✔
196
                        diagnostics.push(requireConditionGroup
3✔
197
                            ? messages.addParenthesisAroundCondition(s)
3✔
198
                            : messages.removeParenthesisAroundCondition(s)
199
                        );
200
                    }
201
                }
202
            },
203
            PrintStatement: s => {
204
                if (validatePrint) {
122✔
205
                    diagnostics.push(messages.noPrint(s.tokens.print.range, noPrint));
2✔
206
                }
207
            },
208
            LiteralExpression: e => {
209
                if (validateColorStyle && e.token.kind === TokenKind.StringLiteral) {
414✔
210
                    validateColorStyle(e.token.text, e.token.range, diagnostics);
63✔
211
                }
212
            },
213
            TemplateStringExpression: e => {
214
                // only validate template strings that look like regular strings (i.e. `0xAABBCC`)
215
                if (validateColorStyle && e.quasis.length === 1 && e.quasis[0].expressions.length === 1) {
11✔
216
                    validateColorStyle(e.quasis[0].expressions[0].token.text, e.quasis[0].expressions[0].token.range, diagnostics);
6✔
217
                }
218
            },
219
            AALiteralExpression: e => {
220
                if (validateAAStyle) {
38✔
221
                    this.validateAAStyle(e, aaCommaStyle, diagnostics);
35✔
222
                }
223
            },
224
            CommentStatement: e => {
225
                if (validateTodo) {
31✔
226
                    if (this.lintContext.todoPattern.test(e.text)) {
18✔
227
                        diagnostics.push(messages.noTodo(e.range, noTodo));
7✔
228
                    }
229
                }
230
            },
231
            StopStatement: s => {
232
                if (validateNoStop) {
1!
233
                    diagnostics.push(messages.noStop(s.tokens.stop.range, noStop));
1✔
234
                }
235
            }
236
        }), { walkMode: walkExpressions ? WalkMode.visitAllRecursive : WalkMode.visitStatementsRecursive });
65✔
237

238
        // validate function style (`function` or `sub`)
239
        for (const fun of file.parser.references.functionExpressions) {
65✔
240
            this.validateFunctionStyle(fun, diagnostics);
145✔
241
        }
242

243
        return diagnostics;
65✔
244
    }
245

246
    validateRegex(file: BrsFile, diagnostics: (Omit<BsDiagnostic, 'file'>)[], severity: DiagnosticSeverity) {
247
        for (const fun of file.parser.references.functionExpressions) {
3✔
248
            const regexes = new Set();
4✔
249
            for (const callExpression of fun.callExpressions) {
4✔
250
                if (!this.isCreateObject(callExpression)) {
13✔
251
                    continue;
4✔
252
                }
253

254
                // Check if all args are literals and get them as string
255
                const callArgs = this.getLiteralArgs(callExpression.args);
9✔
256

257
                // CreateObject for roRegex expects 3 params,
258
                // they should be literals because only in this case we can guarante that call regex is the same
259
                if (callArgs?.length === 3 && callArgs[0] === 'roRegex') {
9!
260
                    const parentStatement = callExpression.findAncestor((node, cancel) => {
9✔
261
                        if (isIfStatement(node)) {
27✔
262
                            cancel.cancel();
2✔
263
                        } else if (this.isLoop(node) || isFunctionExpression(node)) {
25✔
264
                            return true;
7✔
265
                        }
266
                    });
267

268
                    const joinedArgs = callArgs.join();
9✔
269
                    const isRegexAlreadyExist = regexes.has(joinedArgs);
9✔
270
                    if (!isRegexAlreadyExist) {
9✔
271
                        regexes.add(joinedArgs);
7✔
272
                    }
273

274
                    if (isFunctionExpression(parentStatement)) {
9✔
275
                        if (isRegexAlreadyExist) {
5✔
276
                            diagnostics.push(messages.noRegexRedeclaring(callExpression.range, severity));
1✔
277
                        }
278
                    } else if (this.isLoop(parentStatement)) {
4✔
279
                        diagnostics.push(messages.noIdenticalRegexInLoop(callExpression.range, severity));
2✔
280
                    }
281
                }
282
            }
283
        }
284
    }
285

286
    afterFileValidate(file: BscFile) {
287
        if (this.lintContext.ignores(file)) {
67!
288
            return;
×
289
        }
290

291
        const diagnostics: (Omit<BsDiagnostic, 'file'>)[] = [];
67✔
292
        if (isXmlFile(file)) {
67✔
293
            diagnostics.push(...this.validateXMLFile(file));
2✔
294
        } else if (isBrsFile(file)) {
65!
295
            diagnostics.push(...this.validateBrsFile(file));
65✔
296
        }
297

298
        // add file reference
299
        let bsDiagnostics: BsDiagnostic[] = diagnostics.map(diagnostic => ({
138✔
300
            ...diagnostic,
301
            file
302
        }));
303

304
        const { fix } = this.lintContext;
67✔
305

306
        // apply fix
307
        if (fix) {
67✔
308
            bsDiagnostics = extractFixes(this.lintContext.addFixes, bsDiagnostics);
12✔
309
        }
310

311
        // append diagnostics
312
        file.addDiagnostics(bsDiagnostics);
67✔
313
    }
314

315
    validateAAStyle(aa: AALiteralExpression, aaCommaStyle: RuleAAComma, diagnostics: (Omit<BsDiagnostic, 'file'>)[]) {
316
        const indexes = collectWrappingAAMembersIndexes(aa);
35✔
317
        const last = indexes.length - 1;
35✔
318
        const isSingleLine = (aa: AALiteralExpression): boolean => {
35✔
319
            return aa.open.range.start.line === aa.close.range.end.line;
15✔
320
        };
321

322
        indexes.forEach((index, i) => {
35✔
323
            const member = aa.elements[index] as AAMemberExpression;
76✔
324
            const hasComma = !!member.commaToken;
76✔
325
            if (aaCommaStyle === 'never' || (i === last && ((aaCommaStyle === 'no-dangling') || isSingleLine(aa)))) {
76✔
326
                if (hasComma) {
36✔
327
                    diagnostics.push(messages.removeAAComma(member.commaToken.range));
18✔
328
                }
329
            } else if (!hasComma) {
40✔
330
                diagnostics.push(messages.addAAComma(member.value.range));
22✔
331
            }
332
        });
333
    }
334

335
    validateFunctionStyle(fun: FunctionExpression, diagnostics: (Omit<BsDiagnostic, 'file'>)[]) {
336
        const { severity } = this.lintContext;
145✔
337
        const { namedFunctionStyle, anonFunctionStyle, typeAnnotations } = severity;
145✔
338
        const style = fun.functionStatement ? namedFunctionStyle : anonFunctionStyle;
145✔
339
        const kind = fun.functionType.kind;
145✔
340
        const hasReturnedValue = style === 'auto' || typeAnnotations !== 'off' ? this.getFunctionReturns(fun) : false;
145✔
341

342
        // type annotations
343
        if (typeAnnotations !== 'off') {
145✔
344
            if (typeAnnotations !== 'args') {
9✔
345
                if (hasReturnedValue && !fun.returnTypeToken) {
6✔
346
                    diagnostics.push(messages.expectedReturnTypeAnnotation(
2✔
347
                        // add the error to the function keyword (or just highlight the whole function if that's somehow missing)
348
                        fun.functionType?.range ?? fun.range
12!
349
                    ));
350
                }
351
            }
352
            if (typeAnnotations !== 'return') {
9✔
353
                const missingAnnotation = fun.parameters.find(arg => !arg.typeToken);
8✔
354
                if (missingAnnotation) {
6✔
355
                    // only report 1st missing arg annotation to avoid error overload
356
                    diagnostics.push(messages.expectedTypeAnnotation(missingAnnotation.range));
4✔
357
                }
358
            }
359
        }
360

361
        // keyword style
362
        if (style === 'off') {
145✔
363
            return;
55✔
364
        }
365
        if (style === 'no-function') {
90✔
366
            if (kind === TokenKind.Function) {
12✔
367
                diagnostics.push(messages.expectedSubKeyword(fun, `(always use 'sub')`));
6✔
368
            }
369
            return;
12✔
370
        }
371

372
        if (style === 'no-sub') {
78✔
373
            if (kind === TokenKind.Sub) {
11✔
374
                diagnostics.push(messages.expectedFunctionKeyword(fun, `(always use 'function')`));
5✔
375
            }
376
            return;
11✔
377
        }
378

379
        // auto
380
        if (hasReturnedValue) {
67✔
381
            if (kind !== TokenKind.Function) {
6✔
382
                diagnostics.push(messages.expectedFunctionKeyword(fun, `(use 'function' when a value is returned)`));
2✔
383
            }
384
        } else if (kind !== TokenKind.Sub) {
61✔
385
            diagnostics.push(messages.expectedSubKeyword(fun, `(use 'sub' when no value is returned)`));
4✔
386
        }
387
    }
388

389
    getFunctionReturns(fun: FunctionExpression) {
390
        let hasReturnedValue = false;
76✔
391
        if (fun.returnTypeToken) {
76✔
392
            hasReturnedValue = fun.returnTypeToken.kind !== TokenKind.Void;
7✔
393
        } else {
394
            const cancel = new CancellationTokenSource();
69✔
395
            fun.body.walk(createVisitor({
69✔
396
                ReturnStatement: s => {
397
                    hasReturnedValue = !!s.value;
7✔
398
                    cancel.cancel();
7✔
399
                }
400
            }), { walkMode: WalkMode.visitStatements, cancel: cancel.token });
401
        }
402
        return hasReturnedValue;
76✔
403
    }
404

405
    private isLoop(node: AstNode) {
406
        return isForStatement(node) || isForEachStatement(node) || isWhileStatement(node);
29✔
407
    }
408

409
    private isCreateObject(s: CallExpression) {
410
        return isVariableExpression(s.callee) && s.callee.name.text.toLowerCase() === 'createobject';
13✔
411
    }
412

413
    private getLiteralArgs(args: Expression[]) {
414
        const argsStringValue: string[] = [];
9✔
415
        for (const arg of args) {
9✔
416
            if (isLiteralExpression(arg)) {
27!
417
                argsStringValue.push(arg?.token?.text?.replace(/"/g, ''));
27!
418
            } else {
419
                return;
×
420
            }
421
        }
422

423
        return argsStringValue;
9✔
424
    }
425
}
426

427
/**
428
 * Collect indexes of non-inline AA members
429
 */
430
export function collectWrappingAAMembersIndexes(aa: AALiteralExpression): number[] {
1✔
431
    const indexes: number[] = [];
43✔
432
    const { elements } = aa;
43✔
433
    const lastIndex = elements.length - 1;
43✔
434
    for (let i = 0; i < lastIndex; i++) {
43✔
435
        const e = elements[i];
87✔
436
        if (isCommentStatement(e)) {
87✔
437
            continue;
9✔
438
        }
439
        const ne = elements[i + 1];
78✔
440
        const hasNL = isCommentStatement(ne) || ne.range.start.line > e.range.end.line;
78✔
441
        if (hasNL) {
78✔
442
            indexes.push(i);
52✔
443
        }
444
    }
445
    const last = elements[lastIndex];
43✔
446
    if (last && !isCommentStatement(last)) {
43✔
447
        indexes.push(lastIndex);
34✔
448
    }
449
    return indexes;
43✔
450
}
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