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

rokucommunity / bslint / #989

22 Aug 2024 04:03PM CUT coverage: 91.878%. Remained the same
#989

push

TwitchBronBron
0.8.23

844 of 956 branches covered (88.28%)

Branch coverage included in aggregate %.

966 of 1014 relevant lines covered (95.27%)

63.55 hits per line

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

94.2
/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 { addFixesToEvent } from '../../textEdit';
1✔
32
import { PluginContext } from '../../util';
33
import { createColorValidator } from '../../createColorValidator';
1✔
34
import { messages } from './diagnosticMessages';
1✔
35
import { extractFixes } from './styleFixes';
1✔
36

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

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

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

48
    validateXMLFile(file: XmlFile) {
49
        const diagnostics: Omit<BsDiagnostic, 'file'>[] = [];
2✔
50
        const { noArrayComponentFieldType, noAssocarrayComponentFieldType } = this.lintContext.severity;
2✔
51

52
        const validateArrayComponentFieldType = noArrayComponentFieldType !== DiagnosticSeverity.Hint;
2✔
53
        const validateAssocarrayComponentFieldType = noAssocarrayComponentFieldType !== DiagnosticSeverity.Hint;
2✔
54

55
        for (const field of file.parser?.ast?.component?.api?.fields ?? []) {
2!
56
            const { tag, attributes } = field;
6✔
57
            if (tag.text === 'field') {
6!
58
                const typeAttribute = attributes.find(({ key }) => key.text === 'type');
12✔
59

60
                const typeValue = typeAttribute?.value.text;
6!
61
                if (typeValue === 'array' && validateArrayComponentFieldType) {
6✔
62
                    diagnostics.push(
1✔
63
                        messages.noArrayFieldType(
64
                            typeAttribute.value.range,
65
                            noArrayComponentFieldType
66
                        )
67
                    );
68
                } else if (typeValue === 'assocarray' && validateAssocarrayComponentFieldType) {
5✔
69
                    diagnostics.push(
1✔
70
                        messages.noAssocarrayFieldType(
71
                            typeAttribute.value.range,
72
                            noAssocarrayComponentFieldType
73
                        )
74
                    );
75
                }
76
            }
77
        }
78

79
        return diagnostics;
2✔
80
    }
81

82
    validateBrsFile(file: BrsFile) {
83
        const diagnostics: (Omit<BsDiagnostic, 'file'>)[] = [];
61✔
84
        const { severity } = this.lintContext;
61✔
85
        const { inlineIfStyle, blockIfStyle, conditionStyle, noPrint, noTodo, noStop, aaCommaStyle, eolLast, colorFormat, noRegexDuplicates } = severity;
61✔
86
        const validatePrint = noPrint !== DiagnosticSeverity.Hint;
61✔
87
        const validateTodo = noTodo !== DiagnosticSeverity.Hint;
61✔
88
        const validateNoStop = noStop !== DiagnosticSeverity.Hint;
61✔
89
        const validateNoRegexDuplicates = noRegexDuplicates !== DiagnosticSeverity.Hint;
61✔
90
        const validateInlineIf = inlineIfStyle !== 'off';
61✔
91
        const validateColorFormat = (colorFormat === 'hash-hex' || colorFormat === 'quoted-numeric-hex' || colorFormat === 'never');
61✔
92
        const disallowInlineIf = inlineIfStyle === 'never';
61✔
93
        const requireInlineIfThen = inlineIfStyle === 'then';
61✔
94
        const validateBlockIf = blockIfStyle !== 'off';
61✔
95
        const requireBlockIfThen = blockIfStyle === 'then';
61✔
96
        const validateCondition = conditionStyle !== 'off';
61✔
97
        const requireConditionGroup = conditionStyle === 'group';
61✔
98
        const validateAAStyle = aaCommaStyle !== 'off';
61✔
99
        const walkExpressions = validateAAStyle || validateColorFormat;
61✔
100
        const validateEolLast = eolLast !== 'off';
61✔
101
        const disallowEolLast = eolLast === 'never';
61✔
102
        const validateColorStyle = validateColorFormat ? createColorValidator(severity) : undefined;
61✔
103

104
        // Check if the file is empty by going backwards from the last token,
105
        // meaning there are tokens other than `Eof` and `Newline`.
106
        const { tokens } = file.parser;
61✔
107
        let isFileEmpty = true;
61✔
108
        for (let i = tokens.length - 1; i >= 0; i--) {
61✔
109
            if (tokens[i].kind !== TokenKind.Eof &&
171✔
110
                tokens[i].kind !== TokenKind.Newline) {
111
                isFileEmpty = false;
60✔
112
                break;
60✔
113
            }
114
        }
115

116
        // Validate `eol-last` on non-empty files
117
        if (validateEolLast && !isFileEmpty) {
61✔
118
            const penultimateToken = tokens[tokens.length - 2];
39✔
119
            if (disallowEolLast) {
39✔
120
                if (penultimateToken?.kind === TokenKind.Newline) {
2!
121
                    diagnostics.push(messages.removeEolLast(penultimateToken.range));
2✔
122
                }
123
            } else if (penultimateToken?.kind !== TokenKind.Newline) {
37!
124
                // Set the preferredEol as the last newline.
125
                // The fix function will handle the case where preferredEol is undefined.
126
                // This could happen in valid single line files, like:
127
                // `sub foo() end sub\EOF`
128
                let preferredEol;
129
                for (let i = tokens.length - 1; i >= 0; i--) {
3✔
130
                    if (tokens[i].kind === TokenKind.Newline) {
38✔
131
                        preferredEol = tokens[i].text;
4✔
132
                    }
133
                }
134

135
                diagnostics.push(
3✔
136
                    messages.addEolLast(
137
                        penultimateToken.range,
138
                        preferredEol
139
                    )
140
                );
141
            }
142
        }
143

144
        if (validateNoRegexDuplicates) {
61✔
145
            this.validateRegex(file, diagnostics, noRegexDuplicates);
3✔
146
        }
147

148
        file.ast.walk(createVisitor({
61✔
149
            IfStatement: s => {
150
                const hasThenToken = !!s.tokens.then;
76✔
151
                if (!s.isInline && validateBlockIf) {
76✔
152
                    if (hasThenToken !== requireBlockIfThen) {
14✔
153
                        diagnostics.push(requireBlockIfThen
7✔
154
                            ? messages.addBlockIfThenKeyword(s)
7✔
155
                            : messages.removeBlockIfThenKeyword(s)
156
                        );
157
                    }
158
                } else if (s.isInline && validateInlineIf) {
62✔
159
                    if (disallowInlineIf) {
14✔
160
                        diagnostics.push(messages.inlineIfNotAllowed(s.range));
2✔
161
                    } else if (hasThenToken !== requireInlineIfThen) {
12✔
162
                        diagnostics.push(requireInlineIfThen
6✔
163
                            ? messages.addInlineIfThenKeyword(s)
6✔
164
                            : messages.removeInlineIfThenKeyword(s)
165
                        );
166
                    }
167
                }
168

169
                if (validateCondition) {
76✔
170
                    if (isGroupingExpression(s.condition) !== requireConditionGroup) {
22✔
171
                        diagnostics.push(requireConditionGroup
11✔
172
                            ? messages.addParenthesisAroundCondition(s)
11✔
173
                            : messages.removeParenthesisAroundCondition(s)
174
                        );
175
                    }
176
                }
177
            },
178
            WhileStatement: s => {
179
                if (validateCondition) {
12✔
180
                    if (isGroupingExpression(s.condition) !== requireConditionGroup) {
6✔
181
                        diagnostics.push(requireConditionGroup
3✔
182
                            ? messages.addParenthesisAroundCondition(s)
3✔
183
                            : messages.removeParenthesisAroundCondition(s)
184
                        );
185
                    }
186
                }
187
            },
188
            PrintStatement: s => {
189
                if (validatePrint) {
122✔
190
                    diagnostics.push(messages.noPrint(s.tokens.print.range, noPrint));
2✔
191
                }
192
            },
193
            LiteralExpression: e => {
194
                if (validateColorStyle && e.token.kind === TokenKind.StringLiteral) {
404✔
195
                    validateColorStyle(e.token.text, e.token.range, diagnostics);
63✔
196
                }
197
            },
198
            TemplateStringExpression: e => {
199
                // only validate template strings that look like regular strings (i.e. `0xAABBCC`)
200
                if (validateColorStyle && e.quasis.length === 1 && e.quasis[0].expressions.length === 1) {
11✔
201
                    validateColorStyle(e.quasis[0].expressions[0].token.text, e.quasis[0].expressions[0].token.range, diagnostics);
6✔
202
                }
203
            },
204
            AALiteralExpression: e => {
205
                if (validateAAStyle) {
33✔
206
                    this.validateAAStyle(e, aaCommaStyle, diagnostics);
30✔
207
                }
208
            },
209
            CommentStatement: e => {
210
                if (validateTodo) {
31✔
211
                    if (this.lintContext.todoPattern.test(e.text)) {
18✔
212
                        diagnostics.push(messages.noTodo(e.range, noTodo));
7✔
213
                    }
214
                }
215
            },
216
            StopStatement: s => {
217
                if (validateNoStop) {
1!
218
                    diagnostics.push(messages.noStop(s.tokens.stop.range, noStop));
1✔
219
                }
220
            }
221
        }), { walkMode: walkExpressions ? WalkMode.visitAllRecursive : WalkMode.visitStatementsRecursive });
61✔
222

223
        // validate function style (`function` or `sub`)
224
        for (const fun of file.parser.references.functionExpressions) {
61✔
225
            this.validateFunctionStyle(fun, diagnostics);
141✔
226
        }
227

228
        return diagnostics;
61✔
229
    }
230

231
    validateRegex(file: BrsFile, diagnostics: (Omit<BsDiagnostic, 'file'>)[], severity: DiagnosticSeverity) {
232
        for (const fun of file.parser.references.functionExpressions) {
3✔
233
            const regexes = new Set();
4✔
234
            for (const callExpression of fun.callExpressions) {
4✔
235
                if (!this.isCreateObject(callExpression)) {
13✔
236
                    continue;
4✔
237
                }
238

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

242
                // CreateObject for roRegex expects 3 params,
243
                // they should be literals because only in this case we can guarante that call regex is the same
244
                if (callArgs?.length === 3 && callArgs[0] === 'roRegex') {
9!
245
                    const parentStatement = callExpression.findAncestor((node, cancel) => {
9✔
246
                        if (isIfStatement(node)) {
27✔
247
                            cancel.cancel();
2✔
248
                        } else if (this.isLoop(node) || isFunctionExpression(node)) {
25✔
249
                            return true;
7✔
250
                        }
251
                    });
252

253
                    const joinedArgs = callArgs.join();
9✔
254
                    const isRegexAlreadyExist = regexes.has(joinedArgs);
9✔
255
                    if (!isRegexAlreadyExist) {
9✔
256
                        regexes.add(joinedArgs);
7✔
257
                    }
258

259
                    if (isFunctionExpression(parentStatement)) {
9✔
260
                        if (isRegexAlreadyExist) {
5✔
261
                            diagnostics.push(messages.noRegexRedeclaring(callExpression.range, severity));
1✔
262
                        }
263
                    } else if (this.isLoop(parentStatement)) {
4✔
264
                        diagnostics.push(messages.noIdenticalRegexInLoop(callExpression.range, severity));
2✔
265
                    }
266
                }
267
            }
268
        }
269
    }
270

271
    afterFileValidate(file: BscFile) {
272
        if (this.lintContext.ignores(file)) {
63!
273
            return;
×
274
        }
275

276
        const diagnostics: (Omit<BsDiagnostic, 'file'>)[] = [];
63✔
277
        if (isXmlFile(file)) {
63✔
278
            diagnostics.push(...this.validateXMLFile(file));
2✔
279
        } else if (isBrsFile(file)) {
61!
280
            diagnostics.push(...this.validateBrsFile(file));
61✔
281
        }
282

283
        // add file reference
284
        let bsDiagnostics: BsDiagnostic[] = diagnostics.map(diagnostic => ({
126✔
285
            ...diagnostic,
286
            file
287
        }));
288

289
        const { fix } = this.lintContext;
63✔
290

291
        // apply fix
292
        if (fix) {
63✔
293
            bsDiagnostics = extractFixes(this.lintContext.addFixes, bsDiagnostics);
12✔
294
        }
295

296
        // append diagnostics
297
        file.addDiagnostics(bsDiagnostics);
63✔
298
    }
299

300
    validateAAStyle(aa: AALiteralExpression, aaCommaStyle: RuleAAComma, diagnostics: (Omit<BsDiagnostic, 'file'>)[]) {
301
        const indexes = collectWrappingAAMembersIndexes(aa);
30✔
302
        const last = indexes.length - 1;
30✔
303
        const isSingleLine = (aa: AALiteralExpression): boolean => {
30✔
304
            return aa.open.range.start.line === aa.close.range.end.line;
10✔
305
        };
306

307
        indexes.forEach((index, i) => {
30✔
308
            const member = aa.elements[index] as AAMemberExpression;
66✔
309
            const hasComma = !!member.commaToken;
66✔
310
            if (aaCommaStyle === 'never' || (i === last && ((aaCommaStyle === 'no-dangling') || isSingleLine(aa)))) {
66✔
311
                if (hasComma) {
36✔
312
                    diagnostics.push(messages.removeAAComma(member.commaToken.range));
18✔
313
                }
314
            } else if (!hasComma) {
30✔
315
                diagnostics.push(messages.addAAComma(member.value.range));
12✔
316
            }
317
        });
318
    }
319

320
    validateFunctionStyle(fun: FunctionExpression, diagnostics: (Omit<BsDiagnostic, 'file'>)[]) {
321
        const { severity } = this.lintContext;
141✔
322
        const { namedFunctionStyle, anonFunctionStyle, typeAnnotations } = severity;
141✔
323
        const style = fun.functionStatement ? namedFunctionStyle : anonFunctionStyle;
141✔
324
        const kind = fun.functionType.kind;
141✔
325
        const hasReturnedValue = style === 'auto' || typeAnnotations !== 'off' ? this.getFunctionReturns(fun) : false;
141✔
326

327
        // type annotations
328
        if (typeAnnotations !== 'off') {
141✔
329
            if (typeAnnotations !== 'args') {
9✔
330
                if (hasReturnedValue && !fun.returnTypeToken) {
6✔
331
                    diagnostics.push(messages.expectedReturnTypeAnnotation(
2✔
332
                        // add the error to the function keyword (or just highlight the whole function if that's somehow missing)
333
                        fun.functionType?.range ?? fun.range
12!
334
                    ));
335
                }
336
            }
337
            if (typeAnnotations !== 'return') {
9✔
338
                const missingAnnotation = fun.parameters.find(arg => !arg.typeToken);
8✔
339
                if (missingAnnotation) {
6✔
340
                    // only report 1st missing arg annotation to avoid error overload
341
                    diagnostics.push(messages.expectedTypeAnnotation(missingAnnotation.range));
4✔
342
                }
343
            }
344
        }
345

346
        // keyword style
347
        if (style === 'off') {
141✔
348
            return;
51✔
349
        }
350
        if (style === 'no-function') {
90✔
351
            if (kind === TokenKind.Function) {
12✔
352
                diagnostics.push(messages.expectedSubKeyword(fun, `(always use 'sub')`));
6✔
353
            }
354
            return;
12✔
355
        }
356

357
        if (style === 'no-sub') {
78✔
358
            if (kind === TokenKind.Sub) {
11✔
359
                diagnostics.push(messages.expectedFunctionKeyword(fun, `(always use 'function')`));
5✔
360
            }
361
            return;
11✔
362
        }
363

364
        // auto
365
        if (hasReturnedValue) {
67✔
366
            if (kind !== TokenKind.Function) {
6✔
367
                diagnostics.push(messages.expectedFunctionKeyword(fun, `(use 'function' when a value is returned)`));
2✔
368
            }
369
        } else if (kind !== TokenKind.Sub) {
61✔
370
            diagnostics.push(messages.expectedSubKeyword(fun, `(use 'sub' when no value is returned)`));
4✔
371
        }
372
    }
373

374
    getFunctionReturns(fun: FunctionExpression) {
375
        let hasReturnedValue = false;
76✔
376
        if (fun.returnTypeToken) {
76✔
377
            hasReturnedValue = fun.returnTypeToken.kind !== TokenKind.Void;
7✔
378
        } else {
379
            const cancel = new CancellationTokenSource();
69✔
380
            fun.body.walk(createVisitor({
69✔
381
                ReturnStatement: s => {
382
                    hasReturnedValue = !!s.value;
7✔
383
                    cancel.cancel();
7✔
384
                }
385
            }), { walkMode: WalkMode.visitStatements, cancel: cancel.token });
386
        }
387
        return hasReturnedValue;
76✔
388
    }
389

390
    private isLoop(node: AstNode) {
391
        return isForStatement(node) || isForEachStatement(node) || isWhileStatement(node);
29✔
392
    }
393

394
    private isCreateObject(s: CallExpression) {
395
        return isVariableExpression(s.callee) && s.callee.name.text.toLowerCase() === 'createobject';
13✔
396
    }
397

398
    private getLiteralArgs(args: Expression[]) {
399
        const argsStringValue: string[] = [];
9✔
400
        for (const arg of args) {
9✔
401
            if (isLiteralExpression(arg)) {
27!
402
                argsStringValue.push(arg?.token?.text?.replace(/"/g, ''));
27!
403
            } else {
404
                return;
×
405
            }
406
        }
407

408
        return argsStringValue;
9✔
409
    }
410
}
411

412
/**
413
 * Collect indexes of non-inline AA members
414
 */
415
export function collectWrappingAAMembersIndexes(aa: AALiteralExpression): number[] {
1✔
416
    const indexes: number[] = [];
38✔
417
    const { elements } = aa;
38✔
418
    const lastIndex = elements.length - 1;
38✔
419
    for (let i = 0; i < lastIndex; i++) {
38✔
420
        const e = elements[i];
82✔
421
        if (isCommentStatement(e)) {
82✔
422
            continue;
9✔
423
        }
424
        const ne = elements[i + 1];
73✔
425
        const hasNL = isCommentStatement(ne) || ne.range.start.line > e.range.end.line;
73✔
426
        if (hasNL) {
73✔
427
            indexes.push(i);
47✔
428
        }
429
    }
430
    const last = elements[lastIndex];
38✔
431
    if (last && !isCommentStatement(last)) {
38✔
432
        indexes.push(lastIndex);
29✔
433
    }
434
    return indexes;
38✔
435
}
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