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

rokucommunity / brighterscript / #15815

05 May 2026 02:17PM UTC coverage: 88.998% (+0.07%) from 88.927%
#15815

push

web-flow
Add bs:disable / bs:enable block directives and diagnostic suppression quick fixes (#1699)

8668 of 10251 branches covered (84.56%)

Branch coverage included in aggregate %.

178 of 182 new or added lines in 5 files covered. (97.8%)

1 existing line in 1 file now uncovered.

10941 of 11782 relevant lines covered (92.86%)

2039.0 hits per line

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

85.62
/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, isXmlFile } 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
import { getMissingExtendsInsertPosition } from './codeActionHelpers';
1✔
18
import { rangeFromTokenValue } from '../../parser/SGParser';
1✔
19
import type { Range } from 'vscode-languageserver';
20

21
export class CodeActionsProcessor {
1✔
22
    public constructor(
23
        public event: OnGetCodeActionsEvent
65✔
24
    ) {
25

26
    }
27

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

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

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

87
        // Import fix-all aggregates across multiple codes so it runs as its own step
88
        if (
66✔
89
            eventCodes.has(DiagnosticCodeMap.cannotFindName) ||
170✔
90
            eventCodes.has(DiagnosticCodeMap.cannotFindFunction) ||
91
            eventCodes.has(DiagnosticCodeMap.classCouldNotBeFound)
92
        ) {
93
            this.suggestMissingImportsFixAllQuickFix();
22✔
94
        }
95

96
        // Suppression actions appear last so real fixes are surfaced first
97
        for (const diagnostic of this.event.diagnostics) {
66✔
98
            this.suggestDisableDiagnosticQuickFixes(diagnostic);
66✔
99
        }
100

101
        this.suggestedImports.clear();
66✔
102
    }
103

104
    /**
105
     * For any diagnostic with a code, offers two quick-fix actions:
106
     *   - "Disable {code} for this line": adds the code to an existing `bs:disable-line` or
107
     *     `bs:disable-next-line` directive on/above the diagnostic if present, otherwise inserts
108
     *     a new `bs:disable-next-line: {code}` comment on the line above.
109
     *   - "Disable {code} for this file": adds the code to an existing header-level `bs:disable`
110
     *     directive if present, otherwise inserts a new `bs:disable: {code}` at the top of the file.
111
     *
112
     * Comment placement and the line-vs-next-line preference are centralized here so they can be
113
     * revisited without touching the directive parser.
114
     */
115
    private suggestDisableDiagnosticQuickFixes(diagnostic: BsDiagnostic) {
116
        const code = diagnostic.code;
66✔
117
        if (code === undefined || code === null) {
66✔
118
            return;
1✔
119
        }
120
        const file = this.event.file;
65✔
121
        if (!isBrsFile(file) && !isXmlFile(file)) {
65!
NEW
122
            return;
×
123
        }
124
        const codeStr = String(code);
65✔
125
        const isXml = isXmlFile(file);
65✔
126
        //existing.forLine: any line/next-line directive on or above the diagnostic line that the line action could extend
127
        //existing.forFile: any header-level bs:disable that the file action could extend
128
        const existing = this.findExistingDisableDirectives(file, diagnostic.range.start.line);
65✔
129

130
        //format helpers wrap the directive body in the right comment syntax (`'` for brs, `<!-- -->` for xml)
131
        const formatLineDirective = (token: 'line' | 'next-line', codes: string[]) => {
65✔
132
            const body = `bs:disable-${token}: ${codes.join(' ')}`;
63✔
133
            return isXml ? `<!-- ${body} -->` : `' ${body}`;
63✔
134
        };
135
        const formatBlockDirective = (codes: string[]) => {
65✔
136
            const body = `bs:disable: ${codes.join(' ')}`;
65✔
137
            return isXml ? `<!-- ${body} -->` : `' ${body}`;
65✔
138
        };
139

140
        // ---- "disable for this line" ----
141
        //the two lambdas passed to getDiagnosticSuppressionChange are the "extend existing" and "insert fresh" branches:
142
        //  1) rebuild the existing directive comment with the new code merged into its code list (preserving line vs next-line)
143
        //  2) insert a fresh `bs:disable-next-line: {code}` on the line above the diagnostic, matching its indent
144
        const indent = ' '.repeat(diagnostic.range.start.character);
65✔
145
        const lineAction = this.getDiagnosticSuppressionChange(
65✔
146
            existing.forLine,
147
            codeStr,
148
            () => formatLineDirective(existing.forLine!.type as 'line' | 'next-line', this.mergeCodes(existing.forLine?.codes, codeStr)),
2!
149
            () => ({
61✔
150
                position: util.createPosition(diagnostic.range.start.line, 0),
151
                newText: `${indent}${formatLineDirective('next-line', [codeStr])}\n`
152
            })
153
        );
154
        if (lineAction) {
65✔
155
            this.event.codeActions.push(
63✔
156
                codeActionUtil.createCodeAction({
157
                    title: `Disable ${code} for this line: ${diagnostic.message}`,
158
                    diagnostics: [diagnostic],
159
                    kind: CodeActionKind.QuickFix,
160
                    changes: [lineAction]
161
                })
162
            );
163
        }
164

165
        // ---- "disable for this file" ----
166
        //same pattern as above, but operating on the header-level bs:disable directive:
167
        //  1) rebuild the existing header directive with the new code appended
168
        //  2) insert a fresh `bs:disable: {code}` at the file header (top of brs, or after `<?xml ?>` for xml)
169
        const fileAction = this.getDiagnosticSuppressionChange(
65✔
170
            existing.forFile,
171
            codeStr,
172
            () => formatBlockDirective(this.mergeCodes(existing.forFile?.codes, codeStr)),
2!
173
            () => {
174
                const headerInsert = this.getDisableFileInsertion(file);
63✔
175
                return {
63✔
176
                    position: headerInsert.position,
177
                    newText: headerInsert.prefix + formatBlockDirective([codeStr]) + headerInsert.suffix
178
                };
179
            }
180
        );
181
        if (fileAction) {
65!
182
            this.event.codeActions.push(
65✔
183
                codeActionUtil.createCodeAction({
184
                    title: `Disable ${code} for this file: ${diagnostic.message}`,
185
                    diagnostics: [diagnostic],
186
                    kind: CodeActionKind.QuickFix,
187
                    changes: [fileAction]
188
                })
189
            );
190
        }
191
    }
192

193
    /**
194
     * Returns the file change that suppresses `codeStr` via a directive comment, or `null` when no
195
     * change is needed (the existing directive already covers the code, or already suppresses
196
     * everything). When `existing` is set, the result is a replace that swaps the directive comment
197
     * for the text from `buildReplacementText`. When `existing` is null, the result is an insert
198
     * built from `buildInsert`.
199
     */
200
    private getDiagnosticSuppressionChange(
201
        existing: ExistingDirective | null,
202
        codeStr: string,
203
        buildReplacementText: () => string,
204
        buildInsert: () => { position: ReturnType<typeof util.createPosition>; newText: string }
205
    ): InsertChange | ReplaceChange | null {
206
        if (existing) {
130✔
207
            //existing directive without specific codes already suppresses everything; no-op
208
            if (existing.codes.length === 0) {
6✔
209
                return null;
1✔
210
            }
211
            //the new code is already in the directive; no-op
212
            if (existing.codes.some(c => c.toLowerCase() === codeStr.toLowerCase())) {
5✔
213
                return null;
1✔
214
            }
215
            return {
4✔
216
                type: 'replace',
217
                filePath: this.event.file.srcPath,
218
                range: existing.range,
219
                newText: buildReplacementText()
220
            };
221
        }
222
        const insert = buildInsert();
124✔
223
        return {
124✔
224
            type: 'insert',
225
            filePath: this.event.file.srcPath,
226
            position: insert.position,
227
            newText: insert.newText
228
        };
229
    }
230

231
    private mergeCodes(existingCodes: string[] | undefined, newCode: string): string[] {
232
        return [...(existingCodes ?? []), newCode];
4!
233
    }
234

235
    /**
236
     * Walks the file's tokens and returns existing `bs:disable-{line,next-line}` and header-level
237
     * `bs:disable` directives that would cover the diagnostic on `diagLine`. Used so the suppression
238
     * quick fixes can extend an existing directive instead of stacking new ones.
239
     */
240
    private findExistingDisableDirectives(file: BscFile, diagLine: number): { forLine: ExistingDirective | null; forFile: ExistingDirective | null } {
241
        const isXml = isXmlFile(file);
65✔
242
        const tokens: any[] = (file as any).parser?.tokens ?? [];
65!
243
        let inHeader = true;
65✔
244
        let forLine: ExistingDirective | null = null;
65✔
245
        let forFile: ExistingDirective | null = null;
65✔
246
        for (const token of tokens) {
65✔
247
            const isComment = isXml ? token.tokenType?.name === 'Comment' : token.kind === TokenKind.Comment;
1,330!
248
            if (!isComment) {
1,330✔
249
                if (isXml) {
1,320✔
250
                    if (token.tokenType?.name === 'OPEN') {
507!
251
                        inHeader = false;
32✔
252
                    }
253
                } else if (token.kind !== TokenKind.Newline && token.kind !== TokenKind.Whitespace && token.kind !== TokenKind.Eof) {
813✔
254
                    inHeader = false;
521✔
255
                }
256
                continue;
1,320✔
257
            }
258
            const tokenRange: Range = isXml ? rangeFromTokenValue(token) : token.range;
10✔
259
            const tokenText: string = isXml ? token.image : token.text;
10✔
260
            const parsed = parseDisableComment(tokenText);
10✔
261
            if (!parsed) {
10✔
262
                continue;
4✔
263
            }
264
            const directive: ExistingDirective = { type: parsed.directiveType, codes: parsed.codes, range: tokenRange };
6✔
265
            if (!forLine && parsed.directiveType === 'line' && tokenRange.start.line === diagLine) {
6✔
266
                forLine = directive;
1✔
267
            } else if (!forLine && parsed.directiveType === 'next-line' && tokenRange.start.line === diagLine - 1) {
5✔
268
                forLine = directive;
3✔
269
            } else if (!forFile && parsed.directiveType === 'block' && inHeader) {
2!
270
                //only header-level `bs:disable` directives are extended for the file-level quick fix
271
                forFile = directive;
2✔
272
            }
273
        }
274
        return { forLine: forLine, forFile: forFile };
65✔
275
    }
276

277
    /**
278
     * Decides where in the file a header-level `bs:disable` directive should be inserted, returning
279
     * the position plus any prefix/suffix needed so the directive lands on its own line in
280
     * the header (before the first executable statement / root XML element).
281
     */
282
    private getDisableFileInsertion(file: BscFile): { position: ReturnType<typeof util.createPosition>; prefix: string; suffix: string } {
283
        if (isXmlFile(file)) {
63✔
284
            //insert after the `<?xml ?>` declaration if present, otherwise at the very top
285
            const declCloseToken = file.parser.tokens?.find(t => (t as any).tokenType?.name === 'SPECIAL_CLOSE');
130!
286
            if (declCloseToken) {
16✔
287
                const endLine = (declCloseToken as any).endLine - 1;
15✔
288
                const endColumn = (declCloseToken as any).endColumn;
15✔
289
                return {
15✔
290
                    position: util.createPosition(endLine, endColumn),
291
                    prefix: '\n',
292
                    suffix: ''
293
                };
294
            }
295
        }
296
        return {
48✔
297
            position: util.createPosition(0, 0),
298
            prefix: '',
299
            suffix: '\n'
300
        };
301
    }
302

303
    /**
304
     * Builds a map of diagnostic code → all matching diagnostics in the current file for each
305
     * code in `eventCodes`. Scope-level codes are not present in `file.getDiagnostics()` so they
306
     * are sourced from `program.getDiagnostics()` (fetched lazily, only when needed).
307
     */
308
    private collectFixAllDiagnostics(eventCodes: Set<number | string>): Map<number | string, BsDiagnostic[]> {
309
        const scopeLevelCodes = new Set<number | string>([
66✔
310
            DiagnosticCodeMap.referencedFileDoesNotExist,
311
            DiagnosticCodeMap.unnecessaryScriptImportInChildFromParent,
312
            DiagnosticCodeMap.scriptImportCaseMismatch,
313
            DiagnosticCodeMap.missingOverrideKeyword
314
        ]);
315

316
        const fileDiagsByCode = new Map<number | string, BsDiagnostic[]>();
66✔
317
        for (const d of this.event.file.getDiagnostics()) {
66✔
318
            if (!fileDiagsByCode.has(d.code)) {
38✔
319
                fileDiagsByCode.set(d.code, []);
32✔
320
            }
321
            fileDiagsByCode.get(d.code).push(d);
38✔
322
        }
323

324
        const allScopeFileDiags: BsDiagnostic[] = [...eventCodes].some(c => scopeLevelCodes.has(c))
66✔
325
            ? this.event.program.getDiagnostics().filter(d => (d as BsDiagnostic).file === this.event.file) as BsDiagnostic[]
18✔
326
            : [];
327

328
        const result = new Map<number | string, BsDiagnostic[]>();
66✔
329
        for (const code of eventCodes) {
66✔
330
            result.set(
66✔
331
                code,
332
                scopeLevelCodes.has(code)
333
                    ? allScopeFileDiags.filter(d => d.code === code)
18✔
334
                    : fileDiagsByCode.get(code) ?? []
156✔
335
            );
336
        }
337
        return result;
66✔
338
    }
339

340
    private suggestedImports = new Set<string>();
65✔
341

342
    /**
343
     * Generic import suggestion function. Shouldn't be called directly from the main loop, but instead called by more specific diagnostic handlers
344
     */
345
    private suggestImportQuickFix(diagnostic: Diagnostic, key: string, files: BscFile[]) {
346
        //skip if we already have this suggestion
347
        if (this.suggestedImports.has(key)) {
21!
348
            return;
×
349
        }
350

351
        this.suggestedImports.add(key);
21✔
352
        const importStatements = (this.event.file as BrsFile).parser.references.importStatements;
21✔
353
        //find the position of the first import statement, or the top of the file if there is none
354
        const insertPosition = importStatements[importStatements.length - 1]?.importToken.range?.start ?? util.createPosition(0, 0);
21✔
355

356
        //find all files that reference this function
357
        for (const file of files) {
21✔
358
            const pkgPath = util.getRokuPkgPath(file.pkgPath);
17✔
359
            this.event.codeActions.push(
17✔
360
                codeActionUtil.createCodeAction({
361
                    title: `import "${pkgPath}"`,
362
                    diagnostics: [diagnostic],
363
                    isPreferred: false,
364
                    kind: CodeActionKind.QuickFix,
365
                    changes: [{
366
                        type: 'insert',
367
                        filePath: this.event.file.srcPath,
368
                        position: insertPosition,
369
                        newText: `import "${pkgPath}"\n`
370
                    }]
371
                })
372
            );
373
        }
374
    }
375

376
    /**
377
     * Suggests import statements for an unresolved name (function, class, namespace, or enum).
378
     */
379
    private suggestCannotFindNameQuickFix(diagnostic: DiagnosticMessageType<'cannotFindName'>) {
380
        //skip if not a BrighterScript file
381
        if ((diagnostic.file as BrsFile).parseMode !== ParseMode.BrighterScript) {
22✔
382
            return;
1✔
383
        }
384
        const lowerName = (diagnostic.data.fullName ?? diagnostic.data.name).toLowerCase();
21!
385

386
        this.suggestImportQuickFix(
21✔
387
            diagnostic,
388
            lowerName,
389
            [
390
                ...this.event.file.program.findFilesForFunction(lowerName),
391
                ...this.event.file.program.findFilesForClass(lowerName),
392
                ...this.event.file.program.findFilesForNamespace(lowerName),
393
                ...this.event.file.program.findFilesForEnum(lowerName)
394
            ]
395
        );
396
    }
397

398
    /**
399
     * Suggests import statements for an unresolved class name.
400
     */
401
    private suggestClassImportQuickFix(diagnostic: DiagnosticMessageType<'classCouldNotBeFound'>) {
402
        //skip if not a BrighterScript file
403
        if ((diagnostic.file as BrsFile).parseMode !== ParseMode.BrighterScript) {
×
404
            return;
×
405
        }
406
        const lowerClassName = diagnostic.data.className.toLowerCase();
×
407
        this.suggestImportQuickFix(
×
408
            diagnostic,
409
            lowerClassName,
410
            this.event.file.program.findFilesForClass(lowerClassName)
411
        );
412
    }
413

414
    /**
415
     * Scans all import-related diagnostics in the file and emits a single composite
416
     * "Fix all: Add missing imports" action when 2+ unambiguous imports are needed.
417
     * Ambiguous names (multiple possible source files) are excluded since we cannot
418
     * automatically choose one.
419
     */
420
    private suggestMissingImportsFixAllQuickFix() {
421
        if (!isBrsFile(this.event.file) || this.event.file.parseMode !== ParseMode.BrighterScript) {
22✔
422
            return;
1✔
423
        }
424
        const file = this.event.file;
21✔
425
        const importStatements = file.parser.references.importStatements;
21✔
426
        const insertPosition = importStatements[importStatements.length - 1]?.importToken.range?.start ?? util.createPosition(0, 0);
21✔
427

428
        const changes: InsertChange[] = [];
21✔
429
        const addedPaths = new Set<string>();
21✔
430

431
        // cannotFindName/classCouldNotBeFound are scope-level diagnostics, so we must
432
        // use program.getDiagnostics() (filtered by file) rather than file.getDiagnostics().
433
        const allFileDiagnostics = this.event.program.getDiagnostics().filter(d => d.file === file);
52✔
434

435
        for (const diagnostic of allFileDiagnostics) {
21✔
436
            let files: BscFile[] = [];
29✔
437

438
            if (diagnostic.code === DiagnosticCodeMap.cannotFindName || diagnostic.code === DiagnosticCodeMap.cannotFindFunction) {
29✔
439
                const cannotFindNameDiagnostic = diagnostic as DiagnosticMessageType<'cannotFindName'>;
26✔
440
                const lowerName = (cannotFindNameDiagnostic.data?.fullName ?? cannotFindNameDiagnostic.data?.name)?.toLowerCase();
26!
441
                if (lowerName) {
26!
442
                    files = [
26✔
443
                        ...file.program.findFilesForFunction(lowerName),
444
                        ...file.program.findFilesForClass(lowerName),
445
                        ...file.program.findFilesForNamespace(lowerName),
446
                        ...file.program.findFilesForEnum(lowerName)
447
                    ];
448
                }
449
            } else if (diagnostic.code === DiagnosticCodeMap.classCouldNotBeFound) {
3!
450
                const classCouldNotBeFoundDiagnostic = diagnostic as DiagnosticMessageType<'classCouldNotBeFound'>;
×
451
                const lowerClassName = classCouldNotBeFoundDiagnostic.data?.className?.toLowerCase();
×
452
                if (lowerClassName) {
×
453
                    files = file.program.findFilesForClass(lowerClassName);
×
454
                }
455
            }
456

457
            //skip ambiguous names; we can't choose a file automatically
458
            if (files.length !== 1) {
29✔
459
                continue;
13✔
460
            }
461

462
            const pkgPath = util.getRokuPkgPath(files[0].pkgPath);
16✔
463
            if (!addedPaths.has(pkgPath)) {
16✔
464
                addedPaths.add(pkgPath);
15✔
465
                changes.push({
15✔
466
                    type: 'insert',
467
                    filePath: file.srcPath,
468
                    position: insertPosition,
469
                    newText: `import "${pkgPath}"\n`
470
                });
471
            }
472
        }
473

474
        if (changes.length > 1) {
21✔
475
            this.event.codeActions.push(
3✔
476
                codeActionUtil.createCodeAction({
477
                    title: `Fix all: Auto fixable missing imports`,
478
                    kind: CodeActionKind.QuickFix,
479
                    changes: changes
480
                })
481
            );
482
        }
483
    }
484

485
    /**
486
     * Adds code actions to insert a missing `extends` attribute on an XML component tag.
487
     * Offers Group, Task, and ContentNode as common choices.
488
     */
489
    private suggestMissingExtendsQuickFix(diagnostic: DiagnosticMessageType<'xmlComponentMissingExtendsAttribute'>) {
490
        const srcPath = this.event.file.srcPath;
5✔
491
        const pos = getMissingExtendsInsertPosition(this.event.file as XmlFile);
5✔
492
        this.event.codeActions.push(
5✔
493
            codeActionUtil.createCodeAction({
494
                title: `Extend "Group"`,
495
                diagnostics: [diagnostic],
496
                isPreferred: true,
497
                kind: CodeActionKind.QuickFix,
498
                changes: [{
499
                    type: 'insert',
500
                    filePath: srcPath,
501
                    position: pos,
502
                    newText: ' extends="Group"'
503
                }]
504
            })
505
        );
506
        this.event.codeActions.push(
5✔
507
            codeActionUtil.createCodeAction({
508
                title: `Extend "Task"`,
509
                diagnostics: [diagnostic],
510
                kind: CodeActionKind.QuickFix,
511
                changes: [{
512
                    type: 'insert',
513
                    filePath: srcPath,
514
                    position: pos,
515
                    newText: ' extends="Task"'
516
                }]
517
            })
518
        );
519
        this.event.codeActions.push(
5✔
520
            codeActionUtil.createCodeAction({
521
                title: `Extend "ContentNode"`,
522
                diagnostics: [diagnostic],
523
                kind: CodeActionKind.QuickFix,
524
                changes: [{
525
                    type: 'insert',
526
                    filePath: srcPath,
527
                    position: pos,
528
                    newText: ' extends="ContentNode"'
529
                }]
530
            })
531
        );
532
    }
533

534
    /**
535
     * Adds code actions to resolve a `voidFunctionMayNotReturnValue` diagnostic.
536
     * Offers removing the return value, converting sub→function, or removing an `as void` return type.
537
     */
538
    private suggestVoidFunctionReturnQuickFixes(diagnostics: Diagnostic[]) {
539
        const changes = diagnostics.map(d => this.getRemoveReturnValueChange(d));
13✔
540
        this.emitOrFixAll(`Remove return value`, `Fix all: Remove void return values`, changes, diagnostics[0]);
11✔
541

542
        //contextual BrsFile actions only apply to the individual (single-violation) case
543
        if (changes.length === 1 && isBrsFile(this.event.file)) {
11✔
544
            const diagnostic = diagnostics[0];
9✔
545
            const expression = this.event.file.getClosestExpression(diagnostic.range.start);
9✔
546
            const func = expression.findAncestor<FunctionExpression>(isFunctionExpression);
9✔
547

548
            //if we're in a sub and we do not have a return type, suggest converting to a function
549
            if (func.functionType.kind === TokenKind.Sub && !func.returnTypeToken) {
9✔
550
                //find the first function in a file that uses the `function` keyword
551
                const referenceFunction = this.event.file.parser.ast.findChild<FunctionExpression>((node) => {
6✔
552
                    return isFunctionExpression(node) && node.functionType.kind === TokenKind.Function;
48✔
553
                });
554
                const functionTypeText = referenceFunction?.functionType.text ?? 'function';
6!
555
                const endFunctionTypeText = referenceFunction?.end?.text ?? 'end function';
6!
556
                this.event.codeActions.push(
6✔
557
                    codeActionUtil.createCodeAction({
558
                        title: `Convert ${func.functionType.text} to ${functionTypeText}`,
559
                        diagnostics: [diagnostic],
560
                        kind: CodeActionKind.QuickFix,
561
                        changes: [
562
                            //function
563
                            { type: 'replace', filePath: this.event.file.srcPath, range: func.functionType.range, newText: functionTypeText },
564
                            //end function
565
                            { type: 'replace', filePath: this.event.file.srcPath, range: func.end.range, newText: endFunctionTypeText }
566
                        ]
567
                    })
568
                );
569
            }
570

571
            //function `as void` return type. Suggest removing the return type
572
            if (func.functionType.kind === TokenKind.Function && func.returnTypeToken?.kind === TokenKind.Void) {
9!
573
                this.event.codeActions.push(
3✔
574
                    codeActionUtil.createCodeAction({
575
                        title: `Remove return type from function declaration`,
576
                        diagnostics: [diagnostic],
577
                        kind: CodeActionKind.QuickFix,
578
                        changes: [this.getRemoveFunctionReturnTypeChange(func)]
579
                    })
580
                );
581
            }
582
        }
583
    }
584

585
    /**
586
     * Adds code actions to resolve a `nonVoidFunctionMustReturnValue` diagnostic.
587
     * Offers removing the return type from a sub, adding `as void` to a function, or converting function→sub.
588
     */
589
    private suggestNonVoidFunctionReturnQuickFixes(diagnostics: Diagnostic[]) {
590
        if (!isBrsFile(this.event.file)) {
13!
591
            return;
×
592
        }
593
        const file = this.event.file;
13✔
594

595
        //find tokens for `as`, `void`, `sub`, `end sub` in the file if possible
596
        let asText: string;
597
        let voidText: string;
598
        let subText: string;
599
        let endSubText: string;
600
        for (const token of file.parser.tokens) {
13✔
601
            if (asText && voidText && subText && endSubText) {
241!
602
                break;
×
603
            }
604
            if (token?.kind === TokenKind.As) {
241!
605
                asText = token?.text;
12!
606
            } else if (token?.kind === TokenKind.Void) {
229!
607
                voidText = token?.text;
×
608
            } else if (token?.kind === TokenKind.Sub) {
229!
609
                subText = token?.text;
12!
610
            } else if (token?.kind === TokenKind.EndSub) {
217!
611
                endSubText = token?.text;
12!
612
            }
613
        }
614

615
        // Build per-fix-type change arrays, deduplicating by enclosing function so that one
616
        // function with multiple bare returns only contributes one change.
617
        const removeReturnTypeChanges: DeleteChange[] = [];
13✔
618
        const addVoidChanges: InsertChange[] = [];
13✔
619
        const seenFunctions = new Set<string>();
13✔
620

621
        for (const d of diagnostics) {
13✔
622
            const expr = file.getClosestExpression(d.range.start);
17✔
623
            const fn = expr?.findAncestor<FunctionExpression>(isFunctionExpression);
17!
624
            if (!fn) {
17!
625
                continue;
×
626
            }
627
            const fnKey = `${fn.range.start.line}:${fn.range.start.character}`;
17✔
628
            if (seenFunctions.has(fnKey)) {
17✔
629
                continue;
1✔
630
            }
631
            seenFunctions.add(fnKey);
16✔
632

633
            if (fn.functionType.kind === TokenKind.Sub && fn.returnTypeToken && fn.returnTypeToken.kind !== TokenKind.Void) {
16✔
634
                removeReturnTypeChanges.push(this.getRemoveFunctionReturnTypeChange(fn));
10✔
635
            } else if (fn.functionType.kind === TokenKind.Function && !fn.returnTypeToken) {
6!
636
                addVoidChanges.push({
6✔
637
                    type: 'insert',
638
                    filePath: this.event.file.srcPath,
639
                    position: fn.rightParen.range.end,
640
                    newText: ` ${asText ?? 'as'} ${voidText ?? 'void'}`
36!
641
                });
642
            }
643
        }
644

645
        this.emitOrFixAll(
13✔
646
            `Remove return type from sub declaration`,
647
            `Fix all: Remove return type from sub declarations`,
648
            removeReturnTypeChanges,
649
            diagnostics[0]
650
        );
651

652
        this.emitOrFixAll(
13✔
653
            `Add void return type to function declaration`,
654
            `Fix all: Add void return type to function declarations`,
655
            addVoidChanges,
656
            diagnostics[0]
657
        );
658

659
        //'Convert function to sub' has no fix-all variant; only add it for the individual case
660
        if (addVoidChanges.length === 1 && diagnostics.length === 1) {
13✔
661
            const func = file.getClosestExpression(diagnostics[0].range.start).findAncestor<FunctionExpression>(isFunctionExpression);
4✔
662
            this.event.codeActions.push(
4✔
663
                codeActionUtil.createCodeAction({
664
                    title: `Convert function to sub`,
665
                    diagnostics: [diagnostics[0]],
666
                    kind: CodeActionKind.QuickFix,
667
                    changes: [
668
                        { type: 'replace', filePath: file.srcPath, range: func.functionType.range, newText: subText ?? 'sub' },
12!
669
                        { type: 'replace', filePath: file.srcPath, range: func.end.range, newText: endSubText ?? 'end sub' }
12!
670
                    ]
671
                })
672
            );
673
        }
674
    }
675

676
    // ---- script import fixes ----
677

678
    /**
679
     * Adds code actions to delete one or more unnecessary or broken script import lines.
680
     */
681
    private suggestRemoveScriptImportQuickFixes(diagnostics: Diagnostic[]) {
682
        const titles: Record<number, [string, string]> = {
8✔
683
            [DiagnosticCodeMap.unnecessaryScriptImportInChildFromParent]: ['Remove redundant script import', 'Fix all: Remove redundant script imports'],
684
            [DiagnosticCodeMap.unnecessaryCodebehindScriptImport]: ['Remove unnecessary codebehind import', 'Fix all: Remove unnecessary codebehind imports']
685
        };
686
        const [singleTitle, fixAllTitle] = titles[diagnostics[0]?.code] ?? ['Remove script import', 'Fix all: Remove script imports'];
8!
687
        const changes = diagnostics.map<DeleteChange>(diagnostic => {
8✔
688
            return {
10✔
689
                type: 'delete',
690
                filePath: this.event.file.srcPath,
691
                range: util.createRange(
692
                    diagnostic.range.start.line,
693
                    0,
694
                    diagnostic.range.start.line + 1,
695
                    0
696
                )
697
            };
698
        });
699
        this.emitOrFixAll(singleTitle, fixAllTitle, changes, diagnostics[0]);
8✔
700
    }
701

702
    /**
703
     * Adds code actions to correct the casing of script import paths to match the actual file name on disk.
704
     */
705
    private suggestScriptImportCasingQuickFixes(diagnostics: DiagnosticMessageType<'scriptImportCaseMismatch'>[]) {
706
        const changes: ReplaceChange[] = [];
7✔
707
        for (const diagnostic of diagnostics) {
7✔
708
            const correctFilePath = diagnostic.data?.correctFilePath;
8!
709
            if (!correctFilePath) {
8!
710
                continue;
×
711
            }
712
            changes.push({
8✔
713
                type: 'replace',
714
                filePath: this.event.file.srcPath,
715
                range: diagnostic.range,
716
                newText: correctFilePath
717
            });
718
        }
719
        this.emitOrFixAll(
7✔
720
            'Fix script import path casing',
721
            'Fix all: Fix script import path casing',
722
            changes,
723
            diagnostics[0]
724
        );
725
    }
726

727
    // ---- override keyword fixes ----
728

729
    /**
730
     * Adds code actions to insert the missing `override` keyword before a method declaration.
731
     */
732
    private suggestMissingOverrideQuickFixes(diagnostics: Diagnostic[]) {
733
        if (!isBrsFile(this.event.file)) {
4!
734
            return;
×
735
        }
736
        const file = this.event.file;
4✔
737
        const changes: InsertChange[] = [];
4✔
738

739
        for (const diagnostic of diagnostics) {
4✔
740
            let insertPosition: { line: number; character: number } | undefined;
741
            file.ast.walk((node) => {
5✔
742
                if (
42✔
743
                    isMethodStatement(node) &&
63✔
744
                    node.range?.start?.line === diagnostic.range.start.line &&
96!
745
                    node.range?.start?.character === diagnostic.range.start.character
30!
746
                ) {
747
                    insertPosition = (node as MethodStatement).func.functionType?.range?.start;
5!
748
                }
749
            }, { walkMode: WalkMode.visitStatementsRecursive });
750

751
            if (insertPosition) {
5!
752
                changes.push({
5✔
753
                    type: 'insert',
754
                    filePath: file.srcPath,
755
                    position: insertPosition,
756
                    newText: 'override '
757
                });
758
            }
759
        }
760

761
        this.emitOrFixAll(
4✔
762
            `Add missing 'override' keyword`,
763
            `Fix all: Add missing 'override' keywords`,
764
            changes,
765
            diagnostics[0]
766
        );
767
    }
768

769
    /**
770
     * Adds code actions to remove the invalid `override` keyword from a constructor method.
771
     */
772
    private suggestRemoveOverrideFromConstructorQuickFixes(diagnostics: Diagnostic[]) {
773
        const changes: DeleteChange[] = diagnostics.map(d => ({
2✔
774
            type: 'delete' as const,
775
            filePath: this.event.file.srcPath,
776
            // delete "override " (the keyword token plus the trailing space before function/sub)
777
            range: util.createRange(
778
                d.range.start.line,
779
                d.range.start.character,
780
                d.range.end.line,
781
                d.range.end.character + 1
782
            )
783
        }));
784
        this.emitOrFixAll(
2✔
785
            `Remove 'override' from constructor`,
786
            `Fix all: Remove 'override' from constructors`,
787
            changes,
788
            diagnostics[0]
789
        );
790
    }
791

792
    // ---- change helpers ----
793

794
    /**
795
     * Builds a delete change that removes the return value from a `return <expr>` statement,
796
     * leaving just a bare `return`.
797
     */
798
    private getRemoveReturnValueChange(diagnostic: Diagnostic): DeleteChange {
799
        return {
13✔
800
            type: 'delete',
801
            filePath: this.event.file.srcPath,
802
            range: util.createRange(
803
                diagnostic.range.start.line,
804
                diagnostic.range.start.character + 'return'.length,
805
                diagnostic.range.end.line,
806
                diagnostic.range.end.character
807
            )
808
        };
809
    }
810

811
    /**
812
     * Builds the change that deletes `) as <type>` from a function/sub declaration.
813
     * Used for both `as void` on a function and any return type on a sub.
814
     */
815
    private getRemoveFunctionReturnTypeChange(func: FunctionExpression): DeleteChange {
816
        return {
13✔
817
            type: 'delete',
818
            filePath: this.event.file.srcPath,
819
            // )| as <type>|
820
            range: util.createRange(
821
                func.rightParen.range.start.line,
822
                func.rightParen.range.start.character + 1,
823
                func.returnTypeToken.range.end.line,
824
                func.returnTypeToken.range.end.character
825
            )
826
        };
827
    }
828

829
    /**
830
     * Emits a single code action when there is exactly one change, or a "fix all" composite
831
     * action when there are multiple changes (same pattern as ESLint's "Fix all X problems").
832
     * Does nothing when the changes array is empty.
833
     */
834
    private emitOrFixAll(
835
        singleTitle: string,
836
        fixAllTitle: string,
837
        changes: Array<InsertChange | DeleteChange | ReplaceChange>,
838
        diagnostic: Diagnostic
839
    ) {
840
        if (changes.length === 0) {
58✔
841
            return;
13✔
842
        }
843
        if (changes.length === 1) {
45✔
844
            this.event.codeActions.push(
36✔
845
                codeActionUtil.createCodeAction({
846
                    title: singleTitle,
847
                    diagnostics: [diagnostic],
848
                    kind: CodeActionKind.QuickFix,
849
                    changes: changes
850
                })
851
            );
852
        } else {
853
            this.event.codeActions.push(
9✔
854
                codeActionUtil.createCodeAction({
855
                    title: fixAllTitle,
856
                    kind: CodeActionKind.QuickFix,
857
                    changes: changes
858
                })
859
            );
860
        }
861
    }
862
}
863

864
interface ExistingDirective {
865
    type: 'line' | 'next-line' | 'block';
866
    codes: string[];
867
    range: Range;
868
}
869

870
/**
871
 * Parses a comment's text and returns the directive details if it is one. Recognizes
872
 * `'`, `rem`, and `<!-- -->` comment styles. Returns `null` for comments that aren't directives.
873
 * `block` covers `bs:disable`. The `bs:enable` partner isn't surfaced since the quick fix only
874
 * extends `bs:disable` directives.
875
 */
876
function parseDisableComment(text: string): { directiveType: 'line' | 'next-line' | 'block'; codes: string[] } | null {
877
    let inner = text;
10✔
878
    if (inner.startsWith('<!--')) {
10✔
879
        inner = inner.slice('<!--'.length);
1✔
880
        if (inner.endsWith('-->')) {
1!
881
            inner = inner.slice(0, -('-->'.length));
1✔
882
        }
883
    } else if (inner.startsWith(`'`)) {
9!
884
        inner = inner.slice(1);
9✔
NEW
885
    } else if (/^rem\b/i.test(inner)) {
×
NEW
886
        inner = inner.slice('rem'.length);
×
887
    }
888
    inner = inner.trimStart();
10✔
889
    const lower = inner.toLowerCase();
10✔
890
    //match longest-prefix first so `bs:disable-line` doesn't get parsed as `bs:disable`
891
    let directiveType: 'line' | 'next-line' | 'block';
892
    let prefixLength: number;
893
    if (lower.startsWith('bs:disable-next-line')) {
10✔
894
        directiveType = 'next-line';
3✔
895
        prefixLength = 'bs:disable-next-line'.length;
3✔
896
    } else if (lower.startsWith('bs:disable-line')) {
7✔
897
        directiveType = 'line';
1✔
898
        prefixLength = 'bs:disable-line'.length;
1✔
899
    } else if (lower.startsWith('bs:disable')) {
6✔
900
        directiveType = 'block';
2✔
901
        prefixLength = 'bs:disable'.length;
2✔
902
    } else {
903
        return null;
4✔
904
    }
905
    inner = inner.slice(prefixLength);
6✔
906
    if (inner.startsWith(':')) {
6✔
907
        inner = inner.slice(1);
5✔
908
    }
909
    const codes = inner.trim().length === 0 ? [] : inner.trim().split(/\s+/);
6✔
910
    return { directiveType: directiveType, codes: codes };
6✔
911
}
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