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

rokucommunity / brighterscript / #15477

27 Mar 2026 09:09PM UTC coverage: 89.025% (+0.03%) from 88.993%
#15477

push

web-flow
Feature/more quick fixes (#1662)

8116 of 9610 branches covered (84.45%)

Branch coverage included in aggregate %.

145 of 160 new or added lines in 4 files covered. (90.63%)

1 existing line in 1 file now uncovered.

10330 of 11110 relevant lines covered (92.98%)

1985.79 hits per line

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

82.49
/src/bscPlugin/codeActions/CodeActionsProcessor.ts
1
import type { Diagnostic } from 'vscode-languageserver';
2
import { CodeActionKind } from 'vscode-languageserver';
1✔
3
import { codeActionUtil } from '../../CodeActionUtil';
1✔
4
import type { DeleteChange, InsertChange, ReplaceChange } from '../../CodeActionUtil';
5
import type { DiagnosticMessageType } from '../../DiagnosticMessages';
6
import { DiagnosticCodeMap } from '../../DiagnosticMessages';
1✔
7
import type { BrsFile } from '../../files/BrsFile';
8
import type { XmlFile } from '../../files/XmlFile';
9
import type { BscFile, BsDiagnostic, OnGetCodeActionsEvent } from '../../interfaces';
10
import { ParseMode } from '../../parser/Parser';
1✔
11
import { util } from '../../util';
1✔
12
import { isBrsFile, isFunctionExpression, isMethodStatement } from '../../astUtils/reflection';
1✔
13
import type { FunctionExpression } from '../../parser/Expression';
14
import type { MethodStatement } from '../../parser/Statement';
15
import { WalkMode } from '../../astUtils/visitors';
1✔
16
import { TokenKind } from '../../lexer/TokenKind';
1✔
17

18
export class CodeActionsProcessor {
1✔
19
    public constructor(
20
        public event: OnGetCodeActionsEvent
52✔
21
    ) {
22

23
    }
24

25
    /**
26
     * Processes all diagnostics in the event and emits code actions for each recognized diagnostic code.
27
     */
28
    public process() {
29
        // First pass: individual fixes for each diagnostic at the cursor position
30
        for (const diagnostic of this.event.diagnostics) {
53✔
31
            if (diagnostic.code === DiagnosticCodeMap.cannotFindName || diagnostic.code === DiagnosticCodeMap.cannotFindFunction) {
53✔
32
                this.suggestCannotFindNameQuickFix(diagnostic as any);
15✔
33
            } else if (diagnostic.code === DiagnosticCodeMap.classCouldNotBeFound) {
38!
NEW
34
                this.suggestClassImportQuickFix(diagnostic as any);
×
35
            } else if (diagnostic.code === DiagnosticCodeMap.xmlComponentMissingExtendsAttribute) {
38✔
36
                this.suggestMissingExtendsQuickFix(diagnostic as any);
2✔
37
            } else if (diagnostic.code === DiagnosticCodeMap.voidFunctionMayNotReturnValue) {
36✔
38
                this.suggestVoidFunctionReturnQuickFixes([diagnostic]);
9✔
39
            } else if (diagnostic.code === DiagnosticCodeMap.nonVoidFunctionMustReturnValue) {
27✔
40
                this.suggestNonVoidFunctionReturnQuickFixes([diagnostic]);
10✔
41
            } else if (diagnostic.code === DiagnosticCodeMap.referencedFileDoesNotExist) {
17✔
42
                this.suggestRemoveScriptImportQuickFixes([diagnostic]);
3✔
43
            } else if (diagnostic.code === DiagnosticCodeMap.unnecessaryScriptImportInChildFromParent) {
14✔
44
                this.suggestRemoveScriptImportQuickFixes([diagnostic]);
2✔
45
            } else if (diagnostic.code === DiagnosticCodeMap.unnecessaryCodebehindScriptImport) {
12✔
46
                this.suggestRemoveScriptImportQuickFixes([diagnostic]);
1✔
47
            } else if (diagnostic.code === DiagnosticCodeMap.scriptImportCaseMismatch) {
11✔
48
                this.suggestScriptImportCasingQuickFixes([diagnostic as DiagnosticMessageType<'scriptImportCaseMismatch'>]);
6✔
49
            } else if (diagnostic.code === DiagnosticCodeMap.missingOverrideKeyword) {
5✔
50
                this.suggestMissingOverrideQuickFixes([diagnostic]);
3✔
51
            } else if (diagnostic.code === DiagnosticCodeMap.cannotUseOverrideKeywordOnConstructorFunction) {
2!
52
                this.suggestRemoveOverrideFromConstructorQuickFixes([diagnostic]);
2✔
53
            }
54
        }
55

56
        // Second pass: fix-all actions for any code that appeared in the event.
57
        // Also makes sure that fix-all actions appear after individual fixes
58
        const eventCodes = new Set(this.event.diagnostics.map(d => d.code));
53✔
59
        const fixAllDiagsByCode = this.collectFixAllDiagnostics(eventCodes);
53✔
60

61
        // only offer fix-all when there are multiple instances of the same issue in the file
62
        for (const [code, allInFile] of fixAllDiagsByCode) {
53✔
63
            if (allInFile.length > 1) {
53✔
64
                if (code === DiagnosticCodeMap.voidFunctionMayNotReturnValue) {
9✔
65
                    this.suggestVoidFunctionReturnQuickFixes(allInFile);
2✔
66
                } else if (code === DiagnosticCodeMap.nonVoidFunctionMustReturnValue) {
7✔
67
                    this.suggestNonVoidFunctionReturnQuickFixes(allInFile);
3✔
68
                } else if (code === DiagnosticCodeMap.unnecessaryCodebehindScriptImport) {
4!
NEW
69
                    this.suggestRemoveScriptImportQuickFixes(allInFile);
×
70
                } else if (code === DiagnosticCodeMap.cannotUseOverrideKeywordOnConstructorFunction) {
4!
NEW
71
                    this.suggestRemoveOverrideFromConstructorQuickFixes(allInFile);
×
72
                } else if (code === DiagnosticCodeMap.referencedFileDoesNotExist) {
4✔
73
                    this.suggestRemoveScriptImportQuickFixes(allInFile);
1✔
74
                } else if (code === DiagnosticCodeMap.unnecessaryScriptImportInChildFromParent) {
3✔
75
                    this.suggestRemoveScriptImportQuickFixes(allInFile);
1✔
76
                } else if (code === DiagnosticCodeMap.scriptImportCaseMismatch) {
2✔
77
                    this.suggestScriptImportCasingQuickFixes(allInFile as DiagnosticMessageType<'scriptImportCaseMismatch'>[]);
1✔
78
                } else if (code === DiagnosticCodeMap.missingOverrideKeyword) {
1!
79
                    this.suggestMissingOverrideQuickFixes(allInFile);
1✔
80
                }
81
            }
82
        }
83

84
        // Import fix-all aggregates across multiple codes so it runs as its own step
85
        if (
53✔
86
            eventCodes.has(DiagnosticCodeMap.cannotFindName) ||
138✔
87
            eventCodes.has(DiagnosticCodeMap.cannotFindFunction) ||
88
            eventCodes.has(DiagnosticCodeMap.classCouldNotBeFound)
89
        ) {
90
            this.suggestMissingImportsFixAllQuickFix();
15✔
91
        }
92

93
        this.suggestedImports.clear();
53✔
94
    }
95

96
    /**
97
     * Builds a map of diagnostic code → all matching diagnostics in the current file for each
98
     * code in `eventCodes`. Scope-level codes are not present in `file.getDiagnostics()` so they
99
     * are sourced from `program.getDiagnostics()` (fetched lazily, only when needed).
100
     */
101
    private collectFixAllDiagnostics(eventCodes: Set<number | string>): Map<number | string, BsDiagnostic[]> {
102
        const scopeLevelCodes = new Set<number | string>([
53✔
103
            DiagnosticCodeMap.referencedFileDoesNotExist,
104
            DiagnosticCodeMap.unnecessaryScriptImportInChildFromParent,
105
            DiagnosticCodeMap.scriptImportCaseMismatch,
106
            DiagnosticCodeMap.missingOverrideKeyword
107
        ]);
108

109
        const fileDiagsByCode = new Map<number | string, BsDiagnostic[]>();
53✔
110
        for (const d of this.event.file.getDiagnostics()) {
53✔
111
            if (!fileDiagsByCode.has(d.code)) {
30✔
112
                fileDiagsByCode.set(d.code, []);
24✔
113
            }
114
            fileDiagsByCode.get(d.code).push(d);
30✔
115
        }
116

117
        const allScopeFileDiags: BsDiagnostic[] = [...eventCodes].some(c => scopeLevelCodes.has(c))
53✔
118
            ? this.event.program.getDiagnostics().filter(d => (d as BsDiagnostic).file === this.event.file) as BsDiagnostic[]
18✔
119
            : [];
120

121
        const result = new Map<number | string, BsDiagnostic[]>();
53✔
122
        for (const code of eventCodes) {
53✔
123
            result.set(
53✔
124
                code,
125
                scopeLevelCodes.has(code)
126
                    ? allScopeFileDiags.filter(d => d.code === code)
18✔
127
                    : fileDiagsByCode.get(code) ?? []
117✔
128
            );
129
        }
130
        return result;
53✔
131
    }
132

133
    private suggestedImports = new Set<string>();
52✔
134

135
    /**
136
     * Generic import suggestion function. Shouldn't be called directly from the main loop, but instead called by more specific diagnostic handlers
137
     */
138
    private suggestImportQuickFix(diagnostic: Diagnostic, key: string, files: BscFile[]) {
139
        //skip if we already have this suggestion
140
        if (this.suggestedImports.has(key)) {
14!
141
            return;
×
142
        }
143

144
        this.suggestedImports.add(key);
14✔
145
        const importStatements = (this.event.file as BrsFile).parser.references.importStatements;
14✔
146
        //find the position of the first import statement, or the top of the file if there is none
147
        const insertPosition = importStatements[importStatements.length - 1]?.importToken.range?.start ?? util.createPosition(0, 0);
14✔
148

149
        //find all files that reference this function
150
        for (const file of files) {
14✔
151
            const pkgPath = util.getRokuPkgPath(file.pkgPath);
17✔
152
            this.event.codeActions.push(
17✔
153
                codeActionUtil.createCodeAction({
154
                    title: `import "${pkgPath}"`,
155
                    diagnostics: [diagnostic],
156
                    isPreferred: false,
157
                    kind: CodeActionKind.QuickFix,
158
                    changes: [{
159
                        type: 'insert',
160
                        filePath: this.event.file.srcPath,
161
                        position: insertPosition,
162
                        newText: `import "${pkgPath}"\n`
163
                    }]
164
                })
165
            );
166
        }
167
    }
168

169
    /**
170
     * Suggests import statements for an unresolved name (function, class, namespace, or enum).
171
     */
172
    private suggestCannotFindNameQuickFix(diagnostic: DiagnosticMessageType<'cannotFindName'>) {
173
        //skip if not a BrighterScript file
174
        if ((diagnostic.file as BrsFile).parseMode !== ParseMode.BrighterScript) {
15✔
175
            return;
1✔
176
        }
177
        const lowerName = (diagnostic.data.fullName ?? diagnostic.data.name).toLowerCase();
14!
178

179
        this.suggestImportQuickFix(
14✔
180
            diagnostic,
181
            lowerName,
182
            [
183
                ...this.event.file.program.findFilesForFunction(lowerName),
184
                ...this.event.file.program.findFilesForClass(lowerName),
185
                ...this.event.file.program.findFilesForNamespace(lowerName),
186
                ...this.event.file.program.findFilesForEnum(lowerName)
187
            ]
188
        );
189
    }
190

191
    /**
192
     * Suggests import statements for an unresolved class name.
193
     */
194
    private suggestClassImportQuickFix(diagnostic: DiagnosticMessageType<'classCouldNotBeFound'>) {
195
        //skip if not a BrighterScript file
196
        if ((diagnostic.file as BrsFile).parseMode !== ParseMode.BrighterScript) {
×
197
            return;
×
198
        }
199
        const lowerClassName = diagnostic.data.className.toLowerCase();
×
NEW
200
        this.suggestImportQuickFix(
×
201
            diagnostic,
202
            lowerClassName,
203
            this.event.file.program.findFilesForClass(lowerClassName)
204
        );
205
    }
206

207
    /**
208
     * Scans all import-related diagnostics in the file and emits a single composite
209
     * "Fix all: Add missing imports" action when 2+ unambiguous imports are needed.
210
     * Ambiguous names (multiple possible source files) are excluded since we cannot
211
     * automatically choose one.
212
     */
213
    private suggestMissingImportsFixAllQuickFix() {
214
        if (!isBrsFile(this.event.file) || this.event.file.parseMode !== ParseMode.BrighterScript) {
15✔
215
            return;
1✔
216
        }
217
        const file = this.event.file;
14✔
218
        const importStatements = file.parser.references.importStatements;
14✔
219
        const insertPosition = importStatements[importStatements.length - 1]?.importToken.range?.start ?? util.createPosition(0, 0);
14✔
220

221
        const changes: InsertChange[] = [];
14✔
222
        const addedPaths = new Set<string>();
14✔
223

224
        // cannotFindName/classCouldNotBeFound are scope-level diagnostics, so we must
225
        // use program.getDiagnostics() (filtered by file) rather than file.getDiagnostics().
226
        const allFileDiagnostics = this.event.program.getDiagnostics().filter(d => d.file === file);
42✔
227

228
        for (const diagnostic of allFileDiagnostics) {
14✔
229
            let files: BscFile[] = [];
19✔
230

231
            if (diagnostic.code === DiagnosticCodeMap.cannotFindName || diagnostic.code === DiagnosticCodeMap.cannotFindFunction) {
19!
232
                const cannotFindNameDiagnostic = diagnostic as DiagnosticMessageType<'cannotFindName'>;
19✔
233
                const lowerName = (cannotFindNameDiagnostic.data?.fullName ?? cannotFindNameDiagnostic.data?.name)?.toLowerCase();
19!
234
                if (lowerName) {
19!
235
                    files = [
19✔
236
                        ...file.program.findFilesForFunction(lowerName),
237
                        ...file.program.findFilesForClass(lowerName),
238
                        ...file.program.findFilesForNamespace(lowerName),
239
                        ...file.program.findFilesForEnum(lowerName)
240
                    ];
241
                }
NEW
242
            } else if (diagnostic.code === DiagnosticCodeMap.classCouldNotBeFound) {
×
NEW
243
                const classCouldNotBeFoundDiagnostic = diagnostic as DiagnosticMessageType<'classCouldNotBeFound'>;
×
NEW
244
                const lowerClassName = classCouldNotBeFoundDiagnostic.data?.className?.toLowerCase();
×
NEW
245
                if (lowerClassName) {
×
NEW
246
                    files = file.program.findFilesForClass(lowerClassName);
×
247
                }
248
            }
249

250
            //skip ambiguous names — we can't choose a file automatically
251
            if (files.length !== 1) {
19✔
252
                continue;
3✔
253
            }
254

255
            const pkgPath = util.getRokuPkgPath(files[0].pkgPath);
16✔
256
            if (!addedPaths.has(pkgPath)) {
16✔
257
                addedPaths.add(pkgPath);
15✔
258
                changes.push({
15✔
259
                    type: 'insert',
260
                    filePath: file.srcPath,
261
                    position: insertPosition,
262
                    newText: `import "${pkgPath}"\n`
263
                });
264
            }
265
        }
266

267
        if (changes.length > 1) {
14✔
268
            this.event.codeActions.push(
3✔
269
                codeActionUtil.createCodeAction({
270
                    title: `Fix all: Auto fixable missing imports`,
271
                    kind: CodeActionKind.QuickFix,
272
                    changes: changes
273
                })
274
            );
275
        }
276
    }
277

278
    /**
279
     * Adds code actions to insert a missing `extends` attribute on an XML component tag.
280
     * Offers Group, Task, and ContentNode as common choices.
281
     */
282
    private suggestMissingExtendsQuickFix(diagnostic: DiagnosticMessageType<'xmlComponentMissingExtendsAttribute'>) {
283
        const srcPath = this.event.file.srcPath;
2✔
284
        const { component } = (this.event.file as XmlFile).parser.ast;
2✔
285
        //inject new attribute after the final attribute, or after the `<component` if there are no attributes
286
        const pos = (component.attributes[component.attributes.length - 1] ?? component.tag).range.end;
2!
287
        this.event.codeActions.push(
2✔
288
            codeActionUtil.createCodeAction({
289
                title: `Extend "Group"`,
290
                diagnostics: [diagnostic],
291
                isPreferred: true,
292
                kind: CodeActionKind.QuickFix,
293
                changes: [{
294
                    type: 'insert',
295
                    filePath: srcPath,
296
                    position: pos,
297
                    newText: ' extends="Group"'
298
                }]
299
            })
300
        );
301
        this.event.codeActions.push(
2✔
302
            codeActionUtil.createCodeAction({
303
                title: `Extend "Task"`,
304
                diagnostics: [diagnostic],
305
                kind: CodeActionKind.QuickFix,
306
                changes: [{
307
                    type: 'insert',
308
                    filePath: srcPath,
309
                    position: pos,
310
                    newText: ' extends="Task"'
311
                }]
312
            })
313
        );
314
        this.event.codeActions.push(
2✔
315
            codeActionUtil.createCodeAction({
316
                title: `Extend "ContentNode"`,
317
                diagnostics: [diagnostic],
318
                kind: CodeActionKind.QuickFix,
319
                changes: [{
320
                    type: 'insert',
321
                    filePath: srcPath,
322
                    position: pos,
323
                    newText: ' extends="ContentNode"'
324
                }]
325
            })
326
        );
327
    }
328

329
    /**
330
     * Adds code actions to resolve a `voidFunctionMayNotReturnValue` diagnostic.
331
     * Offers removing the return value, converting sub→function, or removing an `as void` return type.
332
     */
333
    private suggestVoidFunctionReturnQuickFixes(diagnostics: Diagnostic[]) {
334
        const changes = diagnostics.map(d => this.getRemoveReturnValueChange(d));
13✔
335
        this.emitOrFixAll(`Remove return value`, `Fix all: Remove void return values`, changes, diagnostics[0]);
11✔
336

337
        //contextual BrsFile actions only apply to the individual (single-violation) case
338
        if (changes.length === 1 && isBrsFile(this.event.file)) {
11✔
339
            const diagnostic = diagnostics[0];
9✔
340
            const expression = this.event.file.getClosestExpression(diagnostic.range.start);
9✔
341
            const func = expression.findAncestor<FunctionExpression>(isFunctionExpression);
9✔
342

343
            //if we're in a sub and we do not have a return type, suggest converting to a function
344
            if (func.functionType.kind === TokenKind.Sub && !func.returnTypeToken) {
9✔
345
                //find the first function in a file that uses the `function` keyword
346
                const referenceFunction = this.event.file.parser.ast.findChild<FunctionExpression>((node) => {
6✔
347
                    return isFunctionExpression(node) && node.functionType.kind === TokenKind.Function;
48✔
348
                });
349
                const functionTypeText = referenceFunction?.functionType.text ?? 'function';
6!
350
                const endFunctionTypeText = referenceFunction?.end?.text ?? 'end function';
6!
351
                this.event.codeActions.push(
6✔
352
                    codeActionUtil.createCodeAction({
353
                        title: `Convert ${func.functionType.text} to ${functionTypeText}`,
354
                        diagnostics: [diagnostic],
355
                        kind: CodeActionKind.QuickFix,
356
                        changes: [
357
                            //function
358
                            { type: 'replace', filePath: this.event.file.srcPath, range: func.functionType.range, newText: functionTypeText },
359
                            //end function
360
                            { type: 'replace', filePath: this.event.file.srcPath, range: func.end.range, newText: endFunctionTypeText }
361
                        ]
362
                    })
363
                );
364
            }
365

366
            //function `as void` return type. Suggest removing the return type
367
            if (func.functionType.kind === TokenKind.Function && func.returnTypeToken?.kind === TokenKind.Void) {
9!
368
                this.event.codeActions.push(
3✔
369
                    codeActionUtil.createCodeAction({
370
                        title: `Remove return type from function declaration`,
371
                        diagnostics: [diagnostic],
372
                        kind: CodeActionKind.QuickFix,
373
                        changes: [this.getRemoveFunctionReturnTypeChange(func)]
374
                    })
375
                );
376
            }
377
        }
378
    }
379

380
    /**
381
     * Adds code actions to resolve a `nonVoidFunctionMustReturnValue` diagnostic.
382
     * Offers removing the return type from a sub, adding `as void` to a function, or converting function→sub.
383
     */
384
    private suggestNonVoidFunctionReturnQuickFixes(diagnostics: Diagnostic[]) {
385
        if (!isBrsFile(this.event.file)) {
13!
NEW
386
            return;
×
387
        }
388
        const file = this.event.file;
13✔
389

390
        //find tokens for `as`, `void`, `sub`, `end sub` in the file if possible
391
        let asText: string;
392
        let voidText: string;
393
        let subText: string;
394
        let endSubText: string;
395
        for (const token of file.parser.tokens) {
13✔
396
            if (asText && voidText && subText && endSubText) {
241!
NEW
397
                break;
×
398
            }
399
            if (token?.kind === TokenKind.As) {
241!
400
                asText = token?.text;
12!
401
            } else if (token?.kind === TokenKind.Void) {
229!
NEW
402
                voidText = token?.text;
×
403
            } else if (token?.kind === TokenKind.Sub) {
229!
404
                subText = token?.text;
12!
405
            } else if (token?.kind === TokenKind.EndSub) {
217!
406
                endSubText = token?.text;
12!
407
            }
408
        }
409

410
        // Build per-fix-type change arrays, deduplicating by enclosing function so that one
411
        // function with multiple bare returns only contributes one change.
412
        const removeReturnTypeChanges: DeleteChange[] = [];
13✔
413
        const addVoidChanges: InsertChange[] = [];
13✔
414
        const seenFunctions = new Set<string>();
13✔
415

416
        for (const d of diagnostics) {
13✔
417
            const expr = file.getClosestExpression(d.range.start);
17✔
418
            const fn = expr?.findAncestor<FunctionExpression>(isFunctionExpression);
17!
419
            if (!fn) {
17!
NEW
420
                continue;
×
421
            }
422
            const fnKey = `${fn.range.start.line}:${fn.range.start.character}`;
17✔
423
            if (seenFunctions.has(fnKey)) {
17✔
424
                continue;
1✔
425
            }
426
            seenFunctions.add(fnKey);
16✔
427

428
            if (fn.functionType.kind === TokenKind.Sub && fn.returnTypeToken && fn.returnTypeToken.kind !== TokenKind.Void) {
16✔
429
                removeReturnTypeChanges.push(this.getRemoveFunctionReturnTypeChange(fn));
10✔
430
            } else if (fn.functionType.kind === TokenKind.Function && !fn.returnTypeToken) {
6!
431
                addVoidChanges.push({
6✔
432
                    type: 'insert',
433
                    filePath: this.event.file.srcPath,
434
                    position: fn.rightParen.range.end,
435
                    newText: ` ${asText ?? 'as'} ${voidText ?? 'void'}`
36!
436
                });
437
            }
438
        }
439

440
        this.emitOrFixAll(
13✔
441
            `Remove return type from sub declaration`,
442
            `Fix all: Remove return type from sub declarations`,
443
            removeReturnTypeChanges,
444
            diagnostics[0]
445
        );
446

447
        this.emitOrFixAll(
13✔
448
            `Add void return type to function declaration`,
449
            `Fix all: Add void return type to function declarations`,
450
            addVoidChanges,
451
            diagnostics[0]
452
        );
453

454
        //'Convert function to sub' has no fix-all variant; only add it for the individual case
455
        if (addVoidChanges.length === 1 && diagnostics.length === 1) {
13✔
456
            const func = file.getClosestExpression(diagnostics[0].range.start).findAncestor<FunctionExpression>(isFunctionExpression);
4✔
457
            this.event.codeActions.push(
4✔
458
                codeActionUtil.createCodeAction({
459
                    title: `Convert function to sub`,
460
                    diagnostics: [diagnostics[0]],
461
                    kind: CodeActionKind.QuickFix,
462
                    changes: [
463
                        { type: 'replace', filePath: file.srcPath, range: func.functionType.range, newText: subText ?? 'sub' },
12!
464
                        { type: 'replace', filePath: file.srcPath, range: func.end.range, newText: endSubText ?? 'end sub' }
12!
465
                    ]
466
                })
467
            );
468
        }
469
    }
470

471
    // ---- script import fixes ----
472

473
    /**
474
     * Adds code actions to delete one or more unnecessary or broken script import lines.
475
     */
476
    private suggestRemoveScriptImportQuickFixes(diagnostics: Diagnostic[]) {
477
        const titles: Record<number, [string, string]> = {
8✔
478
            [DiagnosticCodeMap.unnecessaryScriptImportInChildFromParent]: ['Remove redundant script import', 'Fix all: Remove redundant script imports'],
479
            [DiagnosticCodeMap.unnecessaryCodebehindScriptImport]: ['Remove unnecessary codebehind import', 'Fix all: Remove unnecessary codebehind imports']
480
        };
481
        const [singleTitle, fixAllTitle] = titles[diagnostics[0]?.code] ?? ['Remove script import', 'Fix all: Remove script imports'];
8!
482
        const changes = diagnostics.map<DeleteChange>(diagnostic => {
8✔
483
            return {
10✔
484
                type: 'delete',
485
                filePath: this.event.file.srcPath,
486
                range: util.createRange(
487
                    diagnostic.range.start.line,
488
                    0,
489
                    diagnostic.range.start.line + 1,
490
                    0
491
                )
492
            };
493
        });
494
        this.emitOrFixAll(singleTitle, fixAllTitle, changes, diagnostics[0]);
8✔
495
    }
496

497
    /**
498
     * Adds code actions to correct the casing of script import paths to match the actual file name on disk.
499
     */
500
    private suggestScriptImportCasingQuickFixes(diagnostics: DiagnosticMessageType<'scriptImportCaseMismatch'>[]) {
501
        const changes: ReplaceChange[] = [];
7✔
502
        for (const diagnostic of diagnostics) {
7✔
503
            const correctFilePath = diagnostic.data?.correctFilePath;
8!
504
            if (!correctFilePath) {
8!
NEW
505
                continue;
×
506
            }
507
            changes.push({
8✔
508
                type: 'replace',
509
                filePath: this.event.file.srcPath,
510
                range: diagnostic.range,
511
                newText: correctFilePath
512
            });
513
        }
514
        this.emitOrFixAll(
7✔
515
            'Fix script import path casing',
516
            'Fix all: Fix script import path casing',
517
            changes,
518
            diagnostics[0]
519
        );
520
    }
521

522
    // ---- override keyword fixes ----
523

524
    /**
525
     * Adds code actions to insert the missing `override` keyword before a method declaration.
526
     */
527
    private suggestMissingOverrideQuickFixes(diagnostics: Diagnostic[]) {
528
        if (!isBrsFile(this.event.file)) {
4!
NEW
529
            return;
×
530
        }
531
        const file = this.event.file;
4✔
532
        const changes: InsertChange[] = [];
4✔
533

534
        for (const diagnostic of diagnostics) {
4✔
535
            let insertPosition: { line: number; character: number } | undefined;
536
            file.ast.walk((node) => {
5✔
537
                if (
42✔
538
                    isMethodStatement(node) &&
63✔
539
                    node.range?.start?.line === diagnostic.range.start.line &&
96!
540
                    node.range?.start?.character === diagnostic.range.start.character
30!
541
                ) {
542
                    insertPosition = (node as MethodStatement).func.functionType?.range?.start;
5!
543
                }
544
            }, { walkMode: WalkMode.visitStatementsRecursive });
545

546
            if (insertPosition) {
5!
547
                changes.push({
5✔
548
                    type: 'insert',
549
                    filePath: file.srcPath,
550
                    position: insertPosition,
551
                    newText: 'override '
552
                });
553
            }
554
        }
555

556
        this.emitOrFixAll(
4✔
557
            `Add missing 'override' keyword`,
558
            `Fix all: Add missing 'override' keywords`,
559
            changes,
560
            diagnostics[0]
561
        );
562
    }
563

564
    /**
565
     * Adds code actions to remove the invalid `override` keyword from a constructor method.
566
     */
567
    private suggestRemoveOverrideFromConstructorQuickFixes(diagnostics: Diagnostic[]) {
568
        const changes: DeleteChange[] = diagnostics.map(d => ({
2✔
569
            type: 'delete' as const,
570
            filePath: this.event.file.srcPath,
571
            // delete "override " — the keyword token plus the trailing space before function/sub
572
            range: util.createRange(
573
                d.range.start.line,
574
                d.range.start.character,
575
                d.range.end.line,
576
                d.range.end.character + 1
577
            )
578
        }));
579
        this.emitOrFixAll(
2✔
580
            `Remove 'override' from constructor`,
581
            `Fix all: Remove 'override' from constructors`,
582
            changes,
583
            diagnostics[0]
584
        );
585
    }
586

587
    // ---- change helpers ----
588

589
    /**
590
     * Builds a delete change that removes the return value from a `return <expr>` statement,
591
     * leaving just a bare `return`.
592
     */
593
    private getRemoveReturnValueChange(diagnostic: Diagnostic): DeleteChange {
594
        return {
13✔
595
            type: 'delete',
596
            filePath: this.event.file.srcPath,
597
            range: util.createRange(
598
                diagnostic.range.start.line,
599
                diagnostic.range.start.character + 'return'.length,
600
                diagnostic.range.end.line,
601
                diagnostic.range.end.character
602
            )
603
        };
604
    }
605

606
    /**
607
     * Builds the change that deletes `) as <type>` from a function/sub declaration.
608
     * Used for both `as void` on a function and any return type on a sub.
609
     */
610
    private getRemoveFunctionReturnTypeChange(func: FunctionExpression): DeleteChange {
611
        return {
13✔
612
            type: 'delete',
613
            filePath: this.event.file.srcPath,
614
            // )| as <type>|
615
            range: util.createRange(
616
                func.rightParen.range.start.line,
617
                func.rightParen.range.start.character + 1,
618
                func.returnTypeToken.range.end.line,
619
                func.returnTypeToken.range.end.character
620
            )
621
        };
622
    }
623

624
    /**
625
     * Emits a single code action when there is exactly one change, or a "fix all" composite
626
     * action when there are multiple changes (same pattern as ESLint's "Fix all X problems").
627
     * Does nothing when the changes array is empty.
628
     */
629
    private emitOrFixAll(
630
        singleTitle: string,
631
        fixAllTitle: string,
632
        changes: Array<InsertChange | DeleteChange | ReplaceChange>,
633
        diagnostic: Diagnostic
634
    ) {
635
        if (changes.length === 0) {
58✔
636
            return;
13✔
637
        }
638
        if (changes.length === 1) {
45✔
639
            this.event.codeActions.push(
36✔
640
                codeActionUtil.createCodeAction({
641
                    title: singleTitle,
642
                    diagnostics: [diagnostic],
643
                    kind: CodeActionKind.QuickFix,
644
                    changes: changes
645
                })
646
            );
647
        } else {
648
            this.event.codeActions.push(
9✔
649
                codeActionUtil.createCodeAction({
650
                    title: fixAllTitle,
651
                    kind: CodeActionKind.QuickFix,
652
                    changes: changes
653
                })
654
            );
655
        }
656
    }
657
}
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