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

rokucommunity / bslint / #1150

04 Apr 2025 07:42PM UTC coverage: 91.086% (-0.8%) from 91.844%
#1150

Pull #96

TwitchBronBron
1.0.0-alpha.45
Pull Request #96: v1

976 of 1119 branches covered (87.22%)

Branch coverage included in aggregate %.

262 of 273 new or added lines in 12 files covered. (95.97%)

9 existing lines in 4 files now uncovered.

1037 of 1091 relevant lines covered (95.05%)

71.65 hits per line

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

91.25
/src/plugins/codeStyle/index.ts
1
import {
1✔
2
    BsDiagnostic,
3
    createVisitor,
4
    FunctionExpression,
5
    isBrsFile,
6
    isXmlFile,
7
    isGroupingExpression,
8
    TokenKind,
9
    WalkMode,
10
    CancellationTokenSource,
11
    DiagnosticSeverity,
12
    OnGetCodeActionsEvent,
13
    AALiteralExpression,
14
    BrsFile,
15
    CompilerPlugin,
16
    AfterFileValidateEvent,
17
    Expression,
18
    isVoidType,
19
    Statement,
20
    SymbolTypeFlag,
21
    XmlFile,
22
    AstNode,
23
    Token,
24
    isNamespaceStatement,
25
    util,
26
    isAnyReferenceType,
27
    ExtraSymbolData,
28
    OnScopeValidateEvent,
29
    InternalWalkMode,
30
    isCallableType,
31
    AssignmentStatement,
32
    isFunctionExpression,
33
    isDynamicType
34
} from 'brighterscript';
35
import { RuleAAComma } from '../..';
36
import { addFixesToEvent } from '../../textEdit';
1✔
37
import { PluginContext } from '../../util';
38
import { createColorValidator } from '../../createColorValidator';
1✔
39
import { messages } from './diagnosticMessages';
1✔
40
import { extractFixes } from './styleFixes';
1✔
41
import { BsLintDiagnosticContext } from '../../Linter';
1✔
42
import { Location } from 'vscode-languageserver-types';
43

44
export default class CodeStyle implements CompilerPlugin {
1✔
45

46
    name = 'bslint-codeStyle';
155✔
47

48
    constructor(private lintContext: PluginContext) {
155✔
49
    }
50

51
    onGetCodeActions(event: OnGetCodeActionsEvent) {
UNCOV
52
        const addFixes = addFixesToEvent(event);
×
NEW
53
        extractFixes(event.file, addFixes, event.diagnostics);
×
54
    }
55

56
    validateXMLFile(file: XmlFile) {
57
        const diagnostics: BsDiagnostic[] = [];
2✔
58
        const { noArrayComponentFieldType, noAssocarrayComponentFieldType } = this.lintContext.severity;
2✔
59

60
        const validateArrayComponentFieldType = noArrayComponentFieldType !== DiagnosticSeverity.Hint;
2✔
61
        const validateAssocarrayComponentFieldType = noAssocarrayComponentFieldType !== DiagnosticSeverity.Hint;
2✔
62

63
        for (const field of file.parser?.ast?.componentElement?.interfaceElement?.fields ?? []) {
2!
64
            if (field.tokens.startTagName?.text?.toLowerCase() === 'field') {
6!
65
                const typeAttribute = field.getAttribute('type');
6✔
66

67
                const typeValue = typeAttribute?.tokens?.value?.text?.toLowerCase();
6!
68
                if (typeValue === 'array' && validateArrayComponentFieldType) {
6✔
69
                    diagnostics.push(
1✔
70
                        messages.noArrayFieldType(
71
                            typeAttribute?.tokens?.value?.location,
9!
72
                            noArrayComponentFieldType
73
                        )
74
                    );
75
                } else if (typeValue === 'assocarray' && validateAssocarrayComponentFieldType) {
5✔
76
                    diagnostics.push(
1✔
77
                        messages.noAssocarrayFieldType(
78
                            typeAttribute?.tokens?.value?.location,
9!
79
                            noAssocarrayComponentFieldType
80
                        )
81
                    );
82
                }
83
            }
84
        }
85

86
        return diagnostics;
2✔
87
    }
88

89
    validateBrsFile(file: BrsFile) {
90
        const diagnostics: (BsDiagnostic)[] = [];
68✔
91
        const { severity } = this.lintContext;
68✔
92
        const { inlineIfStyle, blockIfStyle, conditionStyle, noPrint, noTodo, noStop, aaCommaStyle, eolLast, colorFormat } = severity;
68✔
93
        const validatePrint = noPrint !== DiagnosticSeverity.Hint;
68✔
94
        const validateTodo = noTodo !== DiagnosticSeverity.Hint;
68✔
95
        const validateNoStop = noStop !== DiagnosticSeverity.Hint;
68✔
96
        const validateInlineIf = inlineIfStyle !== 'off';
68✔
97
        const validateColorFormat = (colorFormat === 'hash-hex' || colorFormat === 'quoted-numeric-hex' || colorFormat === 'never');
68✔
98
        const disallowInlineIf = inlineIfStyle === 'never';
68✔
99
        const requireInlineIfThen = inlineIfStyle === 'then';
68✔
100
        const validateBlockIf = blockIfStyle !== 'off';
68✔
101
        const requireBlockIfThen = blockIfStyle === 'then';
68✔
102
        const validateCondition = conditionStyle !== 'off';
68✔
103
        const requireConditionGroup = conditionStyle === 'group';
68✔
104
        const validateAAStyle = aaCommaStyle !== 'off';
68✔
105
        const validateEolLast = eolLast !== 'off';
68✔
106
        const disallowEolLast = eolLast === 'never';
68✔
107
        const validateColorStyle = validateColorFormat ? createColorValidator(severity) : undefined;
68✔
108

109
        // Check if the file is empty by going backwards from the last token,
110
        // meaning there are tokens other than `Eof` and `Newline`.
111
        const { tokens } = file.parser;
68✔
112
        let isFileEmpty = true;
68✔
113
        for (let i = tokens.length - 1; i >= 0; i--) {
68✔
114
            if (tokens[i].kind !== TokenKind.Eof &&
192✔
115
                tokens[i].kind !== TokenKind.Newline) {
116
                isFileEmpty = false;
67✔
117
                break;
67✔
118
            }
119
        }
120

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

140
                diagnostics.push(
3✔
141
                    messages.addEolLast(
142
                        penultimateToken.location,
143
                        preferredEol
144
                    )
145
                );
146
            }
147
        }
148

149
        file.ast.walk(createVisitor({
68✔
150
            // validate function style (`function` or `sub`)
151
            FunctionExpression: (func) => {
152
                this.validateFunctionStyle(func, diagnostics);
172✔
153
            },
154
            IfStatement: s => {
155
                const hasThenToken = !!s.tokens.then;
76✔
156
                if (!s.isInline && validateBlockIf) {
76✔
157
                    if (hasThenToken !== requireBlockIfThen) {
16✔
158
                        diagnostics.push(requireBlockIfThen
7✔
159
                            ? messages.addBlockIfThenKeyword(s)
160
                            : messages.removeBlockIfThenKeyword(s)
161
                        );
162
                    }
163
                } else if (s.isInline && validateInlineIf) {
60✔
164
                    if (disallowInlineIf) {
14✔
165
                        diagnostics.push(messages.inlineIfNotAllowed(s.location));
2✔
166
                    } else if (hasThenToken !== requireInlineIfThen) {
12✔
167
                        diagnostics.push(requireInlineIfThen
6✔
168
                            ? messages.addInlineIfThenKeyword(s)
169
                            : messages.removeInlineIfThenKeyword(s)
170
                        );
171
                    }
172
                }
173

174
                if (validateCondition) {
76✔
175
                    if (isGroupingExpression(s.condition) !== requireConditionGroup) {
24✔
176
                        diagnostics.push(requireConditionGroup
11✔
177
                            ? messages.addParenthesisAroundCondition(s)
178
                            : messages.removeParenthesisAroundCondition(s)
179
                        );
180
                    }
181
                }
182
            },
183
            WhileStatement: s => {
184
                if (validateCondition) {
12✔
185
                    if (isGroupingExpression(s.condition) !== requireConditionGroup) {
6✔
186
                        diagnostics.push(requireConditionGroup
3✔
187
                            ? messages.addParenthesisAroundCondition(s)
188
                            : messages.removeParenthesisAroundCondition(s)
189
                        );
190
                    }
191
                }
192
            },
193
            PrintStatement: s => {
194
                if (validatePrint) {
120✔
195
                    diagnostics.push(messages.noPrint(s.tokens.print.location, noPrint));
2✔
196
                }
197
            },
198
            LiteralExpression: e => {
199
                if (validateColorStyle && e.tokens.value.kind === TokenKind.StringLiteral) {
455✔
200
                    validateColorStyle(e.tokens.value.text, e.tokens.value.location, diagnostics);
63✔
201
                }
202
            },
203
            TemplateStringExpression: e => {
204
                // only validate template strings that look like regular strings (i.e. `0xAABBCC`)
205
                if (validateColorStyle && e.quasis.length === 1 && e.quasis[0].expressions.length === 1) {
11✔
206
                    validateColorStyle(e.quasis[0].expressions[0].tokens.value.text, e.quasis[0].expressions[0].tokens.value.location, diagnostics);
6✔
207
                }
208
            },
209
            AALiteralExpression: e => {
210
                if (validateAAStyle) {
36✔
211
                    this.validateAAStyle(e, aaCommaStyle, diagnostics);
33✔
212
                }
213
            },
214
            StopStatement: s => {
215
                if (validateNoStop) {
1!
216
                    diagnostics.push(messages.noStop(s.tokens.stop.location, noStop));
1✔
217
                }
218
            },
219
            AstNode: (node: Statement | Expression) => {
220
                const comments = [...node.leadingTrivia ?? [], ...node.endTrivia ?? []].filter(t => t.kind === TokenKind.Comment);
3,373!
221
                if (validateTodo && comments.length > 0) {
2,137✔
222
                    for (const e of comments) {
6✔
223
                        if (this.lintContext.todoPattern.test(e.text)) {
26✔
224
                            diagnostics.push(messages.noTodo(e.location, noTodo));
10✔
225
                        }
226
                    }
227
                }
228
            }
229
        }), { walkMode: WalkMode.visitAllRecursive });
230

231
        return diagnostics;
68✔
232
    }
233

234
    validateBrsFileInScope(file: BrsFile) {
235
        const diagnostics: (BsDiagnostic)[] = [];
67✔
236
        const { severity } = this.lintContext;
67✔
237
        const { nameShadowing, typeReassignment } = severity;
67✔
238

239
        file.ast.walk(createVisitor({
67✔
240
            NamespaceStatement: (nsStmt) => {
241
                this.validateNameShadowing(file, nsStmt, nsStmt.getNameParts()?.[0], nameShadowing, diagnostics);
4!
242
            },
243
            ClassStatement: (classStmt) => {
244
                this.validateNameShadowing(file, classStmt, classStmt.tokens.name, nameShadowing, diagnostics);
9✔
245
            },
246
            InterfaceStatement: (ifaceStmt) => {
247
                this.validateNameShadowing(file, ifaceStmt, ifaceStmt.tokens.name, nameShadowing, diagnostics);
10✔
248
            },
249
            ConstStatement: (constStmt) => {
250
                this.validateNameShadowing(file, constStmt, constStmt.tokens.name, nameShadowing, diagnostics);
11✔
251
            },
252
            EnumStatement: (enumStmt) => {
253
                this.validateNameShadowing(file, enumStmt, enumStmt.tokens.name, nameShadowing, diagnostics);
7✔
254
            },
255
            AssignmentStatement: (assignStmt) => {
256
                this.validateTypeReassignment(file, assignStmt, typeReassignment, diagnostics);
125✔
257
            }
258
            // eslint-disable-next-line no-bitwise
259
        }), { walkMode: WalkMode.visitStatementsRecursive | InternalWalkMode.visitFalseConditionalCompilationBlocks });
260

261
        return diagnostics;
67✔
262
    }
263

264
    afterFileValidate(event: AfterFileValidateEvent) {
265
        const { file } = event;
69✔
266
        if (this.lintContext.ignores(file)) {
69!
UNCOV
267
            return;
×
268
        }
269

270
        const diagnostics: (BsDiagnostic)[] = [];
69✔
271
        if (isXmlFile(file)) {
69✔
272
            diagnostics.push(...this.validateXMLFile(file));
2✔
273
        } else if (isBrsFile(file)) {
67!
274
            diagnostics.push(...this.validateBrsFile(file));
67✔
275
        }
276

277
        // add file reference
278
        let bsDiagnostics: BsDiagnostic[] = diagnostics.map(diagnostic => ({
134✔
279
            ...diagnostic,
280
            file
281
        }));
282

283
        const { fix } = this.lintContext;
69✔
284

285
        // apply fix
286
        if (fix) {
69✔
287
            bsDiagnostics = extractFixes(event.file, this.lintContext.addFixes, bsDiagnostics);
12✔
288
        }
289

290
        // append diagnostics
291
        event.program.diagnostics.register(bsDiagnostics, BsLintDiagnosticContext);
69✔
292
    }
293

294
    onScopeValidate(event: OnScopeValidateEvent) {
295
        for (const file of event.scope.getOwnFiles()) {
68✔
296
            if (this.lintContext.ignores(file)) {
69!
NEW
297
                return;
×
298
            }
299

300
            const diagnostics: (BsDiagnostic)[] = [];
69✔
301
            if (isBrsFile(file)) {
69✔
302
                diagnostics.push(...this.validateBrsFileInScope(file));
67✔
303
            }
304

305
            // add file reference
306
            let bsDiagnostics: BsDiagnostic[] = diagnostics.map(diagnostic => ({
69✔
307
                ...diagnostic,
308
                file
309
            }));
310

311
            const { fix } = this.lintContext;
69✔
312

313
            // apply fix
314
            if (fix) {
69✔
315
                bsDiagnostics = extractFixes(file, this.lintContext.addFixes, bsDiagnostics);
12✔
316
            }
317

318
            // append diagnostics
319
            event.program.diagnostics.register(bsDiagnostics, BsLintDiagnosticContext);
69✔
320
        }
321
    }
322

323
    validateAAStyle(aa: AALiteralExpression, aaCommaStyle: RuleAAComma, diagnostics: (BsDiagnostic)[]) {
324
        const indexes = collectWrappingAAMembersIndexes(aa);
33✔
325
        const last = indexes.length - 1;
33✔
326
        const isSingleLine = (aa: AALiteralExpression): boolean => {
33✔
327
            return aa.tokens.open.location.range.start.line === aa.tokens.close.location.range.end.line;
10✔
328
        };
329

330
        indexes.forEach((index, i) => {
33✔
331
            const member = aa.elements[index];
69✔
332
            const hasComma = !!member.tokens.comma;
69✔
333
            if (aaCommaStyle === 'never' || (i === last && ((aaCommaStyle === 'no-dangling') || isSingleLine(aa)))) {
69✔
334
                if (hasComma) {
39✔
335
                    diagnostics.push(messages.removeAAComma(member.tokens.comma.location));
18✔
336
                }
337
            } else if (!hasComma) {
30✔
338
                diagnostics.push(messages.addAAComma(member.value.location));
12✔
339
            }
340
        });
341
    }
342

343
    validateFunctionStyle(fun: FunctionExpression, diagnostics: (BsDiagnostic)[]) {
344
        const { severity } = this.lintContext;
172✔
345
        const { namedFunctionStyle, anonFunctionStyle, typeAnnotations } = severity;
172✔
346
        const style = fun.parent ? namedFunctionStyle : anonFunctionStyle;
172!
347
        const kind = fun.tokens.functionType.kind;
172✔
348
        const hasReturnedValue = style === 'auto' || typeAnnotations !== 'off' ? this.getFunctionReturns(fun) : false;
172✔
349

350
        // type annotations
351
        if (typeAnnotations !== 'off') {
172✔
352
            const needsArgType = typeAnnotations.startsWith('args') || typeAnnotations.startsWith('all');
22✔
353
            const needsReturnType = typeAnnotations.startsWith('return') || typeAnnotations.startsWith('all');
22✔
354
            const allowImplicit = typeAnnotations.includes('allow-implicit');
22✔
355

356
            if (needsReturnType) {
22✔
357
                if (hasReturnedValue && !fun.returnTypeExpression) {
13✔
358
                    diagnostics.push(messages.expectedReturnTypeAnnotation(
4✔
359
                        // add the error to the function keyword (or just highlight the whole function if that's somehow missing)
360
                        fun.tokens.functionType?.location ?? fun.location
24!
361
                    ));
362
                }
363
            }
364
            if (needsArgType) {
22✔
365
                const missingAnnotation = fun.parameters.find(arg => {
18✔
366
                    if (!arg.typeExpression) {
26✔
367
                        if (allowImplicit && arg.defaultValue) {
14✔
368
                            return false;
4✔
369
                        }
370
                        return true;
10✔
371
                    }
372
                    return false;
12✔
373
                });
374
                if (missingAnnotation) {
18✔
375
                    // only report 1st missing arg annotation to avoid error overload
376
                    diagnostics.push(messages.expectedTypeAnnotation(missingAnnotation.location));
10✔
377
                }
378
            }
379
        }
380

381
        // keyword style
382
        if (style === 'off') {
172✔
383
            return;
65✔
384
        }
385
        if (style === 'no-function') {
107✔
386
            if (kind === TokenKind.Function) {
12✔
387
                diagnostics.push(messages.expectedSubKeyword(fun, `(always use 'sub')`));
6✔
388
            }
389
            return;
12✔
390
        }
391

392
        if (style === 'no-sub') {
95✔
393
            if (kind === TokenKind.Sub) {
11✔
394
                diagnostics.push(messages.expectedFunctionKeyword(fun, `(always use 'function')`));
5✔
395
            }
396
            return;
11✔
397
        }
398

399
        // auto
400
        if (hasReturnedValue) {
84✔
401
            if (kind !== TokenKind.Function) {
14✔
402
                diagnostics.push(messages.expectedFunctionKeyword(fun, `(use 'function' when a value is returned)`));
2✔
403
            }
404
        } else if (kind !== TokenKind.Sub) {
70✔
405
            diagnostics.push(messages.expectedSubKeyword(fun, `(use 'sub' when no value is returned)`));
4✔
406
        }
407
    }
408

409
    getFunctionReturns(fun: FunctionExpression) {
410
        let hasReturnedValue = false;
106✔
411
        if (fun.returnTypeExpression) {
106✔
412
            hasReturnedValue = !isVoidType(fun.returnTypeExpression.getType({ flags: SymbolTypeFlag.typetime }));
16✔
413
        } else {
414
            const cancel = new CancellationTokenSource();
90✔
415
            fun.body.walk(createVisitor({
90✔
416
                ReturnStatement: s => {
417
                    hasReturnedValue = !!s.value;
17✔
418
                    cancel.cancel();
17✔
419
                }
420
            }), { walkMode: WalkMode.visitStatements, cancel: cancel.token });
421
        }
422
        return hasReturnedValue;
106✔
423
    }
424

425
    validateNameShadowing(file: BrsFile, node: AstNode, nameIdentifier: Token, severity: DiagnosticSeverity, diagnostics: (BsDiagnostic)[]) {
426
        const name = nameIdentifier?.text;
41!
427
        if (!name || !node) {
41!
NEW
428
            return;
×
429
        }
430
        const nameLocation = nameIdentifier.location;
41✔
431

432
        const astTable = file.ast.getSymbolTable();
41✔
433
        const data = {} as ExtraSymbolData;
41✔
434
        const typeChain = [];
41✔
435
        // eslint-disable-next-line no-bitwise
436
        const existingType = astTable.getSymbolType(name, { flags: SymbolTypeFlag.runtime | SymbolTypeFlag.typetime, data: data, typeChain: typeChain });
41✔
437

438
        if (!existingType || isAnyReferenceType(existingType)) {
41!
NEW
439
            return;
×
440
        }
441
        if ((data.definingNode === node) || (isNamespaceStatement(data.definingNode) && isNamespaceStatement(node))) {
41✔
442
            return;
25✔
443
        }
444
        const otherNode = data.definingNode as unknown as { tokens: { name: Token }; location: Location };
16✔
445
        const thisNodeKindName = util.getAstNodeFriendlyName(node);
16✔
446
        let thatNodeKindName = util.getAstNodeFriendlyName(data.definingNode) ?? '';
16✔
447
        if (!thatNodeKindName && isCallableType(existingType)) {
16✔
448
            thatNodeKindName = 'Global Function';
1✔
449
        }
450

451
        let thatNameLocation = otherNode?.tokens?.name?.location ?? otherNode?.location;
16✔
452

453
        if (isNamespaceStatement(data.definingNode)) {
16✔
454
            thatNameLocation = data.definingNode.getNameParts()?.[0]?.location;
2!
455
        }
456

457
        const relatedInformation = thatNameLocation ? [{
16✔
458
            message: `${thatNodeKindName} declared here`,
459
            location: thatNameLocation
460
        }] : undefined;
461

462
        diagnostics.push({
16✔
463
            ...messages.nameShadowing(thisNodeKindName, thatNodeKindName, name, severity),
464
            location: nameLocation,
465
            relatedInformation: relatedInformation
466
        });
467
    }
468

469
    validateTypeReassignment(file: BrsFile, assignStmt: AssignmentStatement, severity: DiagnosticSeverity, diagnostics: (BsDiagnostic)[]) {
470
        const functionExpression = assignStmt.findAncestor<FunctionExpression>(isFunctionExpression);
125✔
471
        if (!functionExpression) {
125!
NEW
472
            return;
×
473
        }
474
        const bodyTable = functionExpression.body?.getSymbolTable();
125!
475
        const rhsType = assignStmt.value?.getType({ flags: SymbolTypeFlag.runtime });
125!
476
        if (!rhsType.isResolvable()) {
125!
NEW
477
            return;
×
478
        }
479
        const varName = assignStmt.tokens.name.text;
125✔
480
        let previousType = functionExpression.getSymbolTable().getSymbolType(varName, { flags: SymbolTypeFlag.runtime });
125✔
481

482
        if (!previousType) {
125✔
483
            // check for last previous assignment
484
            const symbols = bodyTable.getSymbol(varName, SymbolTypeFlag.runtime) ?? [];
114!
485
            for (const symbol of symbols) {
114✔
486
                if (util.comparePosition(symbol.data?.definingNode?.location?.range?.start, assignStmt.location.range.start) < 0) {
173!
487
                    previousType = symbol.type;
59✔
488
                } else {
489
                    break;
114✔
490
                }
491
            }
492
        }
493

494
        if (previousType?.isResolvable()) {
125✔
495
            // is this different?
496
            if (!isDynamicType(previousType)) {
40✔
497
                if (isDynamicType(rhsType) || !previousType.isTypeCompatible(rhsType)) {
39✔
498
                    diagnostics.push({
6✔
499
                        ...messages.typeReassignment(varName, previousType.toString(), rhsType.toString(), severity),
500
                        location: assignStmt.location
501
                    });
502
                }
503
            }
504
        }
505
    }
506
}
507

508
/**
509
 * Collect indexes of non-inline AA members
510
 */
511
export function collectWrappingAAMembersIndexes(aa: AALiteralExpression): number[] {
1✔
512
    const indexes: number[] = [];
41✔
513
    const { elements } = aa;
41✔
514
    const lastIndex = elements.length - 1;
41✔
515
    for (let i = 0; i < lastIndex; i++) {
41✔
516
        const e = elements[i];
66✔
517

518
        const ne = elements[i + 1];
66✔
519
        const hasNL = ne.location.range.start.line > e.location.range.end.line;
66✔
520
        if (hasNL) {
66✔
521
            indexes.push(i);
39✔
522
        }
523
    }
524
    const last = elements[lastIndex];
41✔
525
    if (last) {
41✔
526
        indexes.push(lastIndex);
40✔
527
    }
528
    return indexes;
41✔
529
}
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