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

rokucommunity / brighterscript / #15267

17 Mar 2026 05:30PM UTC coverage: 88.988% (+0.01%) from 88.978%
#15267

push

web-flow
feat(LanguageServer): debounce onDidChangeWatchedFiles events (#1626)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Bronley Plumb <bronley@gmail.com>

7895 of 9356 branches covered (84.38%)

Branch coverage included in aggregate %.

19 of 20 new or added lines in 1 file covered. (95.0%)

20 existing lines in 1 file now uncovered.

10134 of 10904 relevant lines covered (92.94%)

1916.87 hits per line

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

88.76
/src/LanguageServer.ts
1
import * as path from 'path';
1✔
2
import 'array-flat-polyfill';
1✔
3
import type {
4
    CompletionItem,
5
    Connection,
6
    DidChangeWatchedFilesParams,
7
    InitializeParams,
8
    ServerCapabilities,
9
    TextDocumentPositionParams,
10
    ExecuteCommandParams,
11
    WorkspaceSymbolParams,
12
    DocumentSymbolParams,
13
    ReferenceParams,
14
    SignatureHelpParams,
15
    CodeActionParams,
16
    SemanticTokens,
17
    SemanticTokensParams,
18
    TextDocumentChangeEvent,
19
    HandlerResult,
20
    InitializeError,
21
    InitializeResult,
22
    CompletionParams,
23
    ResultProgressReporter,
24
    WorkDoneProgressReporter,
25
    SemanticTokensOptions,
26
    CompletionList,
27
    CancellationToken,
28
    DidChangeConfigurationParams,
29
    DidChangeConfigurationRegistrationOptions
30
} from 'vscode-languageserver/node';
31
import {
1✔
32
    SemanticTokensRequest,
33
    createConnection,
34
    DidChangeConfigurationNotification,
35
    FileChangeType,
36
    ProposedFeatures,
37
    TextDocuments,
38
    TextDocumentSyncKind,
39
    CodeActionKind
40
} from 'vscode-languageserver/node';
41
import { URI } from 'vscode-uri';
1✔
42
import { TextDocument } from 'vscode-languageserver-textdocument';
1✔
43
import { util } from './util';
1✔
44
import { DiagnosticCollection } from './DiagnosticCollection';
1✔
45
import { encodeSemanticTokens, semanticTokensLegend } from './SemanticTokenUtils';
1✔
46
import { LogLevel, createLogger, logger, setLspLoggerProps } from './logging';
1✔
47
import ignore from 'ignore';
1✔
48
import * as micromatch from 'micromatch';
1✔
49
import type { LspProject, LspDiagnostic } from './lsp/LspProject';
50
import { PathFilterer } from './lsp/PathFilterer';
1✔
51
import type { WorkspaceConfig } from './lsp/ProjectManager';
52
import { ProjectManager } from './lsp/ProjectManager';
1✔
53
import * as fsExtra from 'fs-extra';
1✔
54
import type { FileChange, MaybePromise } from './interfaces';
55
import { Deferred } from './deferred';
1✔
56
import { workerPool } from './lsp/worker/WorkerThreadProject';
1✔
57
// eslint-disable-next-line @typescript-eslint/no-require-imports
58
import isEqual = require('lodash.isequal');
1✔
59

60
export class LanguageServer {
1✔
61
    /**
62
     * The default threading setting for the language server. Can be overridden by per-workspace settings
63
     */
64
    public static enableThreadingDefault = true;
1✔
65
    /**
66
     * The default project discovery setting for the language server. Can be overridden by per-workspace settings
67
     */
68
    public static enableProjectDiscoveryDefault = true;
1✔
69
    /**
70
     * The language server protocol connection, used to send and receive all requests and responses
71
     */
72
    private connection = undefined as Connection;
69✔
73

74
    /**
75
     * Manages all projects for this language server
76
     */
77
    private projectManager: ProjectManager;
78

79
    private hasConfigurationCapability = false;
69✔
80

81
    /**
82
     * Indicates whether the client supports workspace folders
83
     */
84
    private clientHasWorkspaceFolderCapability = false;
69✔
85

86
    /**
87
     * Create a simple text document manager.
88
     * The text document manager supports full document sync only
89
     */
90
    private documents = new TextDocuments(TextDocument);
69✔
91

92
    private loggerSubscription: () => void;
93

94
    /**
95
     * Used to filter paths based on include/exclude lists (like .gitignore or vscode's `files.exclude`).
96
     * This is used to prevent the language server from being overwhelmed by files we don't actually want to handle
97
     */
98
    private pathFilterer: PathFilterer;
99

100
    public logger = createLogger({
69✔
101
        logLevel: LogLevel.log
102
    });
103

104
    constructor() {
105
        setLspLoggerProps();
69✔
106
        //replace the workerPool logger with our own so logging info can be synced
107
        workerPool.logger = this.logger.createLogger();
69✔
108

109
        this.pathFilterer = new PathFilterer({ logger: this.logger });
69✔
110

111
        this.projectManager = new ProjectManager({
69✔
112
            pathFilterer: this.pathFilterer,
113
            logger: this.logger.createLogger()
114
        });
115

116
        //anytime a project emits a collection of diagnostics, send them to the client
117
        this.projectManager.on('diagnostics', (event) => {
69✔
118
            this.logger.debug(`Received ${event.diagnostics.length} diagnostics from project ${event.project.projectNumber}`);
55✔
119
            this.sendDiagnostics(event).catch(logAndIgnoreError);
55✔
120
        });
121

122
        // Send all open document changes whenever a project is activated. This is necessary because at project startup, the project loads files from disk
123
        // and may not have the latest unsaved file changes. Any existing projects that already use these files will just ignore the changes
124
        // because the file contents haven't changed.
125

126
        this.projectManager.on('project-activate', (event) => {
69✔
127
            //keep logLevel in sync with the most verbose log level found across all projects
128
            this.syncLogLevel().catch(logAndIgnoreError);
43✔
129

130
            //resend all open document changes
131
            const documents = [...this.documents.all()];
43✔
132
            if (documents.length > 0) {
43✔
133
                this.logger.log(`[${util.getProjectLogName(event.project)}] loaded or changed. Resending all open document changes.`, documents.map(x => x.uri));
47✔
134
                for (const document of this.documents.all()) {
23✔
135
                    this.onTextDocumentDidChangeContent({
47✔
136
                        document: document
137
                    }).catch(logAndIgnoreError);
138
                }
139
            }
140
        });
141

142
        this.projectManager.busyStatusTracker.on('active-runs-change', (event) => {
69✔
143
            this.sendBusyStatus();
422✔
144
        });
145
    }
146

147
    //run the server
148
    public run() {
149
        // Create a connection for the server. The connection uses Node's IPC as a transport.
150
        this.connection = this.establishConnection();
27✔
151

152
        //disable logger colors when running in LSP mode
153
        logger.enableColor = false;
27✔
154

155
        //listen to all of the output log events and pipe them into the debug channel in the extension
156
        this.loggerSubscription = logger.subscribe((message) => {
27✔
157
            this.connection.tracer.log(message.argsText);
150✔
158
        });
159

160
        //bind all our on* methods that share the same name from connection
161
        for (const name of Object.getOwnPropertyNames(LanguageServer.prototype)) {
27✔
162
            if (/on+/.test(name) && typeof this.connection?.[name] === 'function') {
864!
163
                this.connection[name](this[name].bind(this));
378✔
164
            }
165
        }
166

167
        //Register semantic token requests. TODO switch to a more specific connection function call once they actually add it
168
        this.connection.onRequest(SemanticTokensRequest.method, this.onFullSemanticTokens.bind(this));
27✔
169

170
        // The content of a text document has changed. This event is emitted
171
        // when the text document is first opened, when its content has changed,
172
        // or when document is closed without saving (original contents are sent as a change)
173
        //
174
        this.documents.onDidChangeContent(this.onTextDocumentDidChangeContent.bind(this));
27✔
175

176
        //whenever a document gets closed
177
        this.documents.onDidClose(this.onDocumentClose.bind(this));
27✔
178

179
        // listen for open, change and close text document events
180
        this.documents.listen(this.connection);
27✔
181

182
        // Listen on the connection
183
        this.connection.listen();
27✔
184
    }
185

186
    /**
187
     * Called when the client starts initialization
188
     */
189
    @AddStackToErrorMessage
190
    public onInitialize(params: InitializeParams): HandlerResult<InitializeResult, InitializeError> {
1✔
191
        let clientCapabilities = params.capabilities;
1✔
192

193
        // Does the client support the `workspace/configuration` request?
194
        // If not, we will fall back using global settings
195
        this.hasConfigurationCapability = !!(clientCapabilities.workspace && !!clientCapabilities.workspace.configuration);
1✔
196
        this.clientHasWorkspaceFolderCapability = !!(clientCapabilities.workspace && !!clientCapabilities.workspace.workspaceFolders);
1✔
197

198
        //return the capabilities of the server
199
        return {
1✔
200
            capabilities: {
201
                textDocumentSync: TextDocumentSyncKind.Full,
202
                // Tell the client that the server supports code completion
203
                completionProvider: {
204
                    resolveProvider: false,
205
                    //anytime the user types a period, auto-show the completion results
206
                    triggerCharacters: ['.'],
207
                    allCommitCharacters: ['.', '@']
208
                },
209
                documentSymbolProvider: true,
210
                workspaceSymbolProvider: true,
211
                semanticTokensProvider: {
212
                    legend: semanticTokensLegend,
213
                    full: true
214
                } as SemanticTokensOptions,
215
                referencesProvider: true,
216
                codeActionProvider: {
217
                    codeActionKinds: [CodeActionKind.Refactor]
218
                },
219
                signatureHelpProvider: {
220
                    triggerCharacters: ['(', ',']
221
                },
222
                definitionProvider: true,
223
                hoverProvider: true,
224
                executeCommandProvider: {
225
                    commands: [
226
                        CustomCommands.TranspileFile
227
                    ]
228
                }
229
            } as ServerCapabilities
230
        };
231
    }
232

233
    /**
234
     * Called when the client has finished initializing
235
     */
236
    @AddStackToErrorMessage
237
    public async onInitialized() {
1✔
238
        this.logger.log('onInitialized');
3✔
239

240
        //cache a copy of all workspace configurations to use for comparison later
241
        this.workspaceConfigsCache = new Map(
3✔
242
            (await this.getWorkspaceConfigs()).map(x => [x.workspaceFolder, x])
3✔
243
        );
244

245
        //set our logger to the most verbose logLevel found across any project
246
        await this.syncLogLevel();
3✔
247

248
        try {
3✔
249
            if (this.hasConfigurationCapability) {
3✔
250
                // register for when the user changes workspace or user settings
251
                await this.connection.client.register(
2✔
252
                    DidChangeConfigurationNotification.type,
253
                    {
254
                        //we only care about when these settings sections change
255
                        section: [
256
                            'brightscript',
257
                            'files'
258
                        ]
259
                    } as DidChangeConfigurationRegistrationOptions
260
                );
261
            }
262

263
            //populate the path filterer with the client's include/exclude lists
264
            await this.rebuildPathFilterer();
3✔
265

266
            await this.syncProjects();
3✔
267

268
            if (this.clientHasWorkspaceFolderCapability) {
3✔
269
                //if the client changes their workspaces, we need to get our projects in sync
270
                this.connection.workspace.onDidChangeWorkspaceFolders(async (evt) => {
1✔
UNCOV
271
                    await this.syncProjects();
×
272
                });
273
            }
274
        } catch (e: any) {
UNCOV
275
            this.sendCriticalFailure(
×
276
                `Critical failure during BrighterScript language server startup.
277
                Please file a github issue and include the contents of the 'BrighterScript Language Server' output channel.
278

279
                Error message: ${e.message}`
280
            );
UNCOV
281
            throw e;
×
282
        }
283
    }
284

285
    /**
286
     * Set our logLevel to the most verbose log level found across all projects and workspaces
287
     */
288
    private async syncLogLevel() {
289
        /**
290
         * helper to get the logLevel from a list of items and return the item and level (if found), or undefined if not
291
         */
292
        const getLogLevel = async<T>(
86✔
293
            items: T[],
294
            fetcher: (item: T) => MaybePromise<LogLevel | string>
295
        ): Promise<{ logLevel: LogLevel; logLevelText: string; item: T }> => {
296
            const logLevels = await Promise.all(
161✔
297
                items.map(async (item) => {
298
                    let value = await fetcher(item);
179✔
299
                    //force string values to lower case (so we can support things like 'log' or 'Log' or 'LOG')
300
                    if (typeof value === 'string') {
179✔
301
                        value = value.toLowerCase();
14✔
302
                    }
303
                    const logLevelNumeric = this.logger.getLogLevelNumeric(value as any);
179✔
304

305
                    if (typeof logLevelNumeric === 'number') {
179✔
306
                        return logLevelNumeric;
98✔
307
                    } else {
308
                        return -1;
81✔
309
                    }
310
                })
311
            );
312
            let idx = logLevels.findIndex(x => x > -1);
161✔
313
            if (idx > -1) {
161✔
314
                const mostVerboseLogLevel = Math.max(...logLevels);
79✔
315
                return {
79✔
316
                    logLevel: mostVerboseLogLevel,
317
                    logLevelText: this.logger.getLogLevelText(mostVerboseLogLevel),
318
                    //find the first item having the most verbose logLevel
319
                    item: items[logLevels.findIndex(x => x === mostVerboseLogLevel)]
80✔
320
                };
321
            }
322
        };
323

324
        const workspaces = await this.getWorkspaceConfigs();
86✔
325

326
        let workspaceResult = await getLogLevel(workspaces, workspace => workspace?.languageServer?.logLevel);
89!
327

328
        if (workspaceResult) {
84✔
329
            this.logger.info(`Setting global logLevel to '${workspaceResult.logLevelText}' based on configuration from workspace '${workspaceResult?.item?.workspaceFolder}'`);
7!
330
            this.logger.logLevel = workspaceResult.logLevel;
7✔
331
            return;
7✔
332
        }
333

334
        let projectResult = await getLogLevel(this.projectManager.projects, (project) => project.logger.logLevel);
90✔
335
        if (projectResult) {
77✔
336
            this.logger.info(`Setting global logLevel to '${projectResult.logLevelText}' based on project #${projectResult?.item?.projectNumber}`);
72!
337
            this.logger.logLevel = projectResult.logLevel;
72✔
338
            return;
72✔
339
        }
340

341
        //use a default level if no other level was found
342
        this.logger.logLevel = LogLevel.log;
5✔
343
    }
344

345
    @AddStackToErrorMessage
346
    private async onTextDocumentDidChangeContent(event: TextDocumentChangeEvent<TextDocument>) {
1✔
347
        this.logger.debug('onTextDocumentDidChangeContent', event.document.uri);
49✔
348

349
        await this.projectManager.handleFileChanges([{
49✔
350
            srcPath: URI.parse(event.document.uri).fsPath,
351
            type: FileChangeType.Changed,
352
            fileContents: event.document.getText(),
353
            allowStandaloneProject: true
354
        }]);
355
    }
356

357
    /**
358
     * Pending file changes waiting to be flushed after the debounce period
359
     */
360
    private pendingFileChanges: FileChange[] = [];
69✔
361

362
    /**
363
     * Timer handle for the file change debounce
364
     */
365
    private fileChangeDebounceTimer: ReturnType<typeof setTimeout> | undefined;
366

367
    /**
368
     * How long to wait (in ms) after the last file change event before processing the batch.
369
     * This prevents excessive revalidation during bulk operations like `git checkout` or package installs.
370
     */
371
    public fileChangeDebounceDelay = 300;
69✔
372

373
    /**
374
     * Called when watched files changed (add/change/delete).
375
     * The CLIENT is in charge of what files to watch, so all client
376
     * implementations should ensure that all valid project
377
     * file types are watched (.brs,.bs,.xml,manifest, and any json/text/image files)
378
     *
379
     * File changes are debounced to batch rapid successive events (e.g. during builds or VCS operations)
380
     * into a single processing pass, reducing redundant work across projects.
381
     */
382
    @AddStackToErrorMessage
383
    public async onDidChangeWatchedFiles(params: DidChangeWatchedFilesParams) {
1✔
384
        const workspacePaths = (await this.connection.workspace.getWorkspaceFolders()).map(x => util.uriToPath(x.uri));
23✔
385

386
        const changes = params.changes
23✔
387
            .map(x => ({
23✔
388
                srcPath: util.uriToPath(x.uri),
389
                type: x.type,
390
                //if this is an open document, allow this file to be loaded in a standalone project (if applicable)
391
                allowStandaloneProject: this.documents.get(x.uri) !== undefined
392
            }))
393
            //exclude all explicit top-level workspace folder paths (to fix a weird macos fs watcher bug that emits events for the workspace folder itself)
394
            .filter(x => !workspacePaths.includes(x.srcPath));
23✔
395

396
        this.logger.debug('onDidChangeWatchedFiles', changes);
23✔
397

398
        //accumulate changes into the pending buffer
399
        this.pendingFileChanges.push(...changes);
23✔
400

401
        //reset the debounce timer so we batch rapid successive events
402
        clearTimeout(this.fileChangeDebounceTimer);
23✔
403

404
        //use a deferred so callers can await the completion of the flush
405
        if (!this.pendingFileChangesDeferred) {
23✔
406
            this.pendingFileChangesDeferred = new Deferred();
16✔
407
        }
408
        const deferred = this.pendingFileChangesDeferred;
23✔
409

410
        this.fileChangeDebounceTimer = setTimeout(() => {
23✔
411
            void this.flushFileChanges().then(
16✔
412
                () => deferred.resolve(),
16✔
NEW
413
                (err) => deferred.reject(err)
×
414
            );
415
        }, this.fileChangeDebounceDelay);
416

417
        return deferred.promise;
23✔
418
    }
419

420
    /**
421
     * Deferred for the current pending file changes batch
422
     */
423
    private pendingFileChangesDeferred: Deferred | undefined;
424

425
    /**
426
     * Flush all pending file changes accumulated during the debounce window
427
     */
428
    private async flushFileChanges() {
429
        //grab all pending changes and clear the buffer, deduping by srcPath (last event wins)
430
        const deduped = new Map<string, FileChange>();
16✔
431
        for (const change of this.pendingFileChanges.splice(0, this.pendingFileChanges.length)) {
16✔
432
            deduped.set(change.srcPath, change);
22✔
433
        }
434
        const changes = [...deduped.values()];
16✔
435
        this.pendingFileChangesDeferred = undefined;
16✔
436

437
        //if the client changed any files containing include/exclude patterns, rebuild the path filterer before processing these changes
438
        if (
16✔
439
            micromatch.some(changes.map(x => x.srcPath), [
19✔
440
                '**/.gitignore',
441
                '**/.vscode/settings.json',
442
                '**/*bsconfig*.json'
443
            ], {
444
                dot: true
445
            })
446
        ) {
447
            await this.rebuildPathFilterer();
8✔
448
        }
449

450
        //handle the file changes
451
        await this.projectManager.handleFileChanges(changes);
16✔
452
    }
453

454
    @AddStackToErrorMessage
455
    private async onDocumentClose(event: TextDocumentChangeEvent<TextDocument>): Promise<void> {
1✔
456
        this.logger.debug('onDocumentClose', event.document.uri);
1✔
457

458
        await this.projectManager.handleFileClose({
1✔
459
            srcPath: util.uriToPath(event.document.uri)
460
        });
461
    }
462

463
    /**
464
     * Provide a list of completion items based on the current cursor position
465
     */
466
    @AddStackToErrorMessage
467
    public async onCompletion(params: CompletionParams, cancellationToken: CancellationToken, workDoneProgress: WorkDoneProgressReporter, resultProgress: ResultProgressReporter<CompletionItem[]>): Promise<CompletionList> {
1✔
468
        this.logger.debug('onCompletion', params, cancellationToken);
1✔
469

470
        const srcPath = util.uriToPath(params.textDocument.uri);
1✔
471
        const completions = await this.projectManager.getCompletions({
1✔
472
            srcPath: srcPath,
473
            position: params.position,
474
            cancellationToken: cancellationToken
475
        });
476
        return completions;
1✔
477
    }
478

479
    /**
480
     * Get a list of workspaces, and their configurations.
481
     * Get only the settings for the workspace that are relevant to the language server. We do this so we can cache this object for use in change detection in the future.
482
     */
483
    private async getWorkspaceConfigs(): Promise<WorkspaceConfig[]> {
484
        //get all workspace folders (we'll use these to get settings)
485
        let workspaces = await Promise.all(
129✔
486
            (await this.connection.workspace.getWorkspaceFolders() ?? []).map(async (x) => {
383!
487
                const workspaceFolder = util.uriToPath(x.uri);
133✔
488
                const brightscriptConfig = await this.getClientConfiguration<BrightScriptClientConfiguration>(x.uri, 'brightscript');
133✔
489
                return {
133✔
490
                    workspaceFolder: workspaceFolder,
491
                    excludePatterns: await this.getWorkspaceExcludeGlobs(workspaceFolder),
492
                    projects: this.normalizeProjectPaths(workspaceFolder, brightscriptConfig?.projects),
398✔
493
                    languageServer: {
494
                        enableThreading: brightscriptConfig?.languageServer?.enableThreading ?? LanguageServer.enableThreadingDefault,
1,196✔
495
                        enableProjectDiscovery: brightscriptConfig?.languageServer?.enableProjectDiscovery ?? LanguageServer.enableProjectDiscoveryDefault,
1,196✔
496
                        projectDiscoveryMaxDepth: brightscriptConfig?.languageServer?.projectDiscoveryMaxDepth ?? 15,
1,196!
497
                        projectDiscoveryExclude: brightscriptConfig?.languageServer?.projectDiscoveryExclude,
797✔
498
                        logLevel: brightscriptConfig?.languageServer?.logLevel
797✔
499

500
                    }
501
                };
502
            })
503
        );
504
        return workspaces;
127✔
505
    }
506

507
    /**
508
     * Extract project paths from settings' projects list, expanding the workspaceFolder variable if necessary
509
     */
510
    private normalizeProjectPaths(workspaceFolder: string, projects: (string | BrightScriptProjectConfiguration)[]): BrightScriptProjectConfiguration[] | undefined {
511
        return projects?.reduce((acc, project) => {
133✔
512
            if (typeof project === 'string') {
3✔
513
                acc.push({ path: project });
2✔
514
            } else if (typeof project.path === 'string') {
1!
515
                acc.push(project);
1✔
516
            }
517
            return acc;
3✔
518
        }, []).map(project => ({
3✔
519
            ...project,
520
            // eslint-disable-next-line no-template-curly-in-string
521
            path: util.standardizePath(project.path.replace('${workspaceFolder}', workspaceFolder))
522
        }));
523
    }
524

525
    private workspaceConfigsCache = new Map<string, WorkspaceConfig>();
69✔
526

527
    @AddStackToErrorMessage
528
    public async onDidChangeConfiguration(args: DidChangeConfigurationParams) {
1✔
529
        this.logger.log('onDidChangeConfiguration', 'Reloading all projects');
5✔
530

531
        const configs = new Map(
5✔
532
            (await this.getWorkspaceConfigs()).map(x => [x.workspaceFolder, x])
4✔
533
        );
534
        //find any changed configs. This includes newly created workspaces, deleted workspaces, etc.
535
        //TODO: enhance this to only reload specific projects, depending on the change
536
        if (!isEqual(configs, this.workspaceConfigsCache)) {
5✔
537
            //now that we've processed any config diffs, update the cached copy of them
538
            this.workspaceConfigsCache = configs;
3✔
539

540
            //if configuration changed, rebuild the path filterer
541
            await this.rebuildPathFilterer();
3✔
542

543
            //if the user changes any user/workspace config settings, just mass-reload all projects
544
            await this.syncProjects(true);
3✔
545
        }
546
    }
547

548

549
    @AddStackToErrorMessage
550
    public async onHover(params: TextDocumentPositionParams) {
1✔
UNCOV
551
        this.logger.debug('onHover', params);
×
552

UNCOV
553
        const srcPath = util.uriToPath(params.textDocument.uri);
×
UNCOV
554
        const result = await this.projectManager.getHover({ srcPath: srcPath, position: params.position });
×
UNCOV
555
        return result;
×
556
    }
557

558
    @AddStackToErrorMessage
559
    public async onWorkspaceSymbol(params: WorkspaceSymbolParams) {
1✔
560
        this.logger.debug('onWorkspaceSymbol', params);
4✔
561

562
        const result = await this.projectManager.getWorkspaceSymbol();
4✔
563
        return result;
4✔
564
    }
565

566
    @AddStackToErrorMessage
567
    public async onDocumentSymbol(params: DocumentSymbolParams) {
1✔
568
        this.logger.debug('onDocumentSymbol', params);
6✔
569

570
        const srcPath = util.uriToPath(params.textDocument.uri);
6✔
571
        const result = await this.projectManager.getDocumentSymbol({ srcPath: srcPath });
6✔
572
        return result;
6✔
573
    }
574

575
    @AddStackToErrorMessage
576
    public async onDefinition(params: TextDocumentPositionParams) {
1✔
577
        this.logger.debug('onDefinition', params);
5✔
578

579
        const srcPath = util.uriToPath(params.textDocument.uri);
5✔
580

581
        const result = this.projectManager.getDefinition({ srcPath: srcPath, position: params.position });
5✔
582
        return result;
5✔
583
    }
584

585
    @AddStackToErrorMessage
586
    public async onSignatureHelp(params: SignatureHelpParams) {
1✔
587
        this.logger.debug('onSignatureHelp', params);
4✔
588

589
        const srcPath = util.uriToPath(params.textDocument.uri);
4✔
590
        const result = await this.projectManager.getSignatureHelp({ srcPath: srcPath, position: params.position });
4✔
591
        if (result) {
4✔
592
            return result;
3✔
593
        } else {
594
            return {
1✔
595
                signatures: [],
596
                activeSignature: null,
597
                activeParameter: null
598
            };
599
        }
600

601
    }
602

603
    @AddStackToErrorMessage
604
    public async onReferences(params: ReferenceParams) {
1✔
605
        this.logger.debug('onReferences', params);
3✔
606

607
        const srcPath = util.uriToPath(params.textDocument.uri);
3✔
608
        const result = await this.projectManager.getReferences({ srcPath: srcPath, position: params.position });
3✔
609
        return result ?? [];
3!
610
    }
611

612

613
    @AddStackToErrorMessage
614
    private async onFullSemanticTokens(params: SemanticTokensParams) {
1✔
615
        this.logger.debug('onFullSemanticTokens', params);
1✔
616

617
        const srcPath = util.uriToPath(params.textDocument.uri);
1✔
618
        const result = await this.projectManager.getSemanticTokens({ srcPath: srcPath });
1✔
619

620
        return {
1✔
621
            data: encodeSemanticTokens(result)
622
        } as SemanticTokens;
623
    }
624

625
    @AddStackToErrorMessage
626
    public async onCodeAction(params: CodeActionParams) {
1✔
UNCOV
627
        this.logger.debug('onCodeAction', params);
×
628

UNCOV
629
        const srcPath = util.uriToPath(params.textDocument.uri);
×
UNCOV
630
        const result = await this.projectManager.getCodeActions({ srcPath: srcPath, range: params.range });
×
UNCOV
631
        return result;
×
632
    }
633

634

635
    @AddStackToErrorMessage
636
    public async onExecuteCommand(params: ExecuteCommandParams) {
1✔
637
        this.logger.debug('onExecuteCommand', params);
2✔
638

639
        if (params.command === CustomCommands.TranspileFile) {
2!
640
            const args = {
2✔
641
                srcPath: params.arguments[0] as string
642
            };
643
            const result = await this.projectManager.transpileFile(args);
2✔
644
            //back-compat: include `pathAbsolute` property so older vscode versions still work
645
            (result as any).pathAbsolute = result.srcPath;
2✔
646
            return result;
2✔
647
        }
648
    }
649

650
    /**
651
     * Establish a connection to the client if not already connected
652
     */
653
    private establishConnection() {
UNCOV
654
        if (!this.connection) {
×
UNCOV
655
            this.connection = createConnection(ProposedFeatures.all);
×
656
        }
UNCOV
657
        return this.connection;
×
658
    }
659

660
    /**
661
     * Send a new busy status notification to the client based on the current busy status
662
     */
663
    private sendBusyStatus() {
664
        this.busyStatusIndex = ++this.busyStatusIndex <= 0 ? 0 : this.busyStatusIndex;
422✔
665

666
        this.connection.sendNotification(NotificationName.busyStatus, {
422!
667
            status: this.projectManager.busyStatusTracker.status,
668
            timestamp: Date.now(),
669
            index: this.busyStatusIndex,
670
            activeRuns: [
671
                //extract only specific information from the active run so we know what's going on
672
                ...this.projectManager.busyStatusTracker.activeRuns.map(x => ({
513✔
673
                    scope: util.getProjectLogName(x.scope),
674
                    label: x.label,
675
                    startTime: x.startTime.getTime()
676
                }))
677
            ]
678
        })?.catch(logAndIgnoreError);
422!
679
    }
680
    private busyStatusIndex = -1;
69✔
681

682
    private pathFiltererDisposables: Array<() => void> = [];
69✔
683

684
    /**
685
     * Populate the path filterer with the client's include/exclude lists and the projects include lists
686
     * @returns the instance of the path filterer
687
     */
688
    private async rebuildPathFilterer() {
689
        //dispose of any previous pathFilterer disposables
690
        this.pathFiltererDisposables?.forEach(dispose => dispose());
18!
691
        //keep track of all the pathFilterer disposables so we can dispose them later
692
        this.pathFiltererDisposables = [];
18✔
693

694
        const workspaceConfigs = await this.getWorkspaceConfigs();
18✔
695
        await Promise.all(workspaceConfigs.map(async (workspaceConfig) => {
18✔
696
            const rootDir = util.uriToPath(workspaceConfig.workspaceFolder);
19✔
697

698
            //always exclude everything from these common folders
699
            this.pathFiltererDisposables.push(
19✔
700
                this.pathFilterer.registerExcludeList(rootDir, [
701
                    '**/node_modules/**/*',
702
                    '**/.git/**/*',
703
                    'out/**/*',
704
                    '**/.roku-deploy-staging/**/*'
705
                ])
706
            );
707
            //get any `files.exclude` patterns from the client from this workspace
708
            this.pathFiltererDisposables.push(
19✔
709
                this.pathFilterer.registerExcludeList(rootDir, workspaceConfig.excludePatterns)
710
            );
711

712
            //get any .gitignore patterns from the client from this workspace
713
            const gitignorePath = path.resolve(rootDir, '.gitignore');
19✔
714
            if (await fsExtra.pathExists(gitignorePath)) {
19✔
715
                const matcher = ignore({ ignoreCase: true }).add(
4✔
716
                    fsExtra.readFileSync(gitignorePath).toString()
717
                );
718
                this.pathFiltererDisposables.push(
4✔
719
                    this.pathFilterer.registerExcludeMatcher((p: string) => {
720
                        const relPath = path.relative(rootDir, p);
8✔
721
                        if (ignore.isPathValid(relPath)) {
8✔
722
                            return matcher.test(relPath).ignored;
5✔
723
                        } else {
724
                            //we do not have a valid relative path, so we cannot determine if it is ignored...thus it is NOT ignored
725
                            return false;
3✔
726
                        }
727
                    })
728
                );
729
            }
730
        }));
731
        this.logger.log('pathFilterer successfully reconstructed');
18✔
732

733
        return this.pathFilterer;
18✔
734
    }
735

736
    /**
737
     * Ask the client for the list of `files.exclude` and `files.watcherExclude` patterns. Useful when determining if we should process a file
738
     */
739
    private async getWorkspaceExcludeGlobs(workspaceFolder: string): Promise<string[]> {
740
        const filesConfig = await this.getClientConfiguration<{ exclude: Record<string, boolean>; watcherExclude: Record<string, boolean> }>(workspaceFolder, 'files');
139✔
741
        const searchConfig = await this.getClientConfiguration<{ exclude: Record<string, boolean> }>(workspaceFolder, 'search');
139✔
742
        const languageServerConfig = await this.getClientConfiguration<BrightScriptClientConfiguration>(workspaceFolder, 'brightscript');
139✔
743

744
        return [
139✔
745
            ...this.extractExcludes(filesConfig?.exclude),
415✔
746
            ...this.extractExcludes(filesConfig?.watcherExclude),
415✔
747
            ...this.extractExcludes(searchConfig?.exclude),
415✔
748
            ...this.extractExcludes(languageServerConfig?.languageServer?.projectDiscoveryExclude)
832✔
749
        ];
750
    }
751

752
    private extractExcludes(exclude: Record<string, boolean>): string[] {
753
        //if the exclude is not defined, return an empty array
754
        if (!exclude) {
556✔
755
            return [];
531✔
756
        }
757
        return Object
25✔
758
            .keys(exclude)
759
            .filter(x => exclude[x])
29✔
760
            //vscode files.exclude patterns support ignoring folders without needing to add `**/*`. So for our purposes, we need to
761
            //append **/* to everything without a file extension or magic at the end
762
            .map(pattern => {
763
                const result = [
29✔
764
                    //send the pattern as-is (this handles weird cases and exact file matches)
765
                    pattern
766
                ];
767
                //treat the pattern as a directory (no harm in doing this because if it's a file, the pattern will just never match anything)
768
                if (!pattern.endsWith('/**/*')) {
29✔
769
                    result.push(`${pattern}/**/*`);
24✔
770
                }
771
                return result;
29✔
772
            })
773
            .flat(1);
774
    }
775

776
    /**
777
     * Ask the project manager to sync all projects found within the list of workspaces
778
     * @param forceReload if true, all projects are discarded and recreated from scratch
779
     */
780
    private async syncProjects(forceReload = false) {
34✔
781
        const workspaces = await this.getWorkspaceConfigs();
34✔
782

783
        await this.projectManager.syncProjects(workspaces, forceReload);
34✔
784

785
        //set our logLevel to the most verbose log level found across all projects and workspaces
786
        await this.syncLogLevel();
34✔
787
    }
788

789
    /**
790
     * Given a workspaceFolder path, get the specified configuration from the client (if applicable).
791
     * Be sure to use optional chaining to traverse the result in case that configuration doesn't exist or the client doesn't support `getConfiguration`
792
     * @param workspaceFolder the folder for the workspace in the client
793
     */
794
    private async getClientConfiguration<T extends Record<string, any>>(workspaceFolder: string, section: string): Promise<T> {
795
        const scopeUri = util.pathToUri(workspaceFolder);
482✔
796
        let config = {};
482✔
797

798
        //if the client supports configuration, look for config group called "brightscript"
799
        if (this.hasConfigurationCapability) {
482✔
800
            config = await this.connection.workspace.getConfiguration({
457✔
801
                scopeUri: scopeUri,
802
                section: section
803
            });
804
        }
805
        return config as T;
482✔
806
    }
807

808
    /**
809
     * Send a critical failure notification to the client, which should show a notification of some kind
810
     */
811
    private sendCriticalFailure(message: string) {
UNCOV
812
        this.connection.sendNotification('critical-failure', message).catch(logAndIgnoreError);
×
813
    }
814

815
    /**
816
     * Send diagnostics to the client
817
     */
818
    private async sendDiagnostics(options: { project: LspProject; diagnostics: LspDiagnostic[] }) {
819
        const patch = this.diagnosticCollection.getPatch(options.project.projectNumber, options.diagnostics);
55✔
820

821
        await Promise.all(Object.keys(patch).map(async (srcPath) => {
55✔
822
            const uri = URI.file(srcPath).toString();
17✔
823
            const diagnostics = patch[srcPath].map(d => util.toDiagnostic(d, uri));
17✔
824

825
            await this.connection.sendDiagnostics({
17✔
826
                uri: uri,
827
                diagnostics: diagnostics
828
            });
829
        }));
830
    }
831
    private diagnosticCollection = new DiagnosticCollection();
69✔
832

833
    protected dispose() {
834
        clearTimeout(this.fileChangeDebounceTimer);
69✔
835
        this.loggerSubscription?.();
69✔
836
        this.projectManager?.dispose?.();
69!
837
    }
838
}
839

840
export enum CustomCommands {
1✔
841
    TranspileFile = 'TranspileFile'
1✔
842
}
843

844
export enum NotificationName {
1✔
845
    busyStatus = 'busyStatus'
1✔
846
}
847

848
/**
849
 * Wraps a method. If there's an error (either sync or via a promise),
850
 * this appends the error's stack trace at the end of the error message so that the connection will
851
 */
852
function AddStackToErrorMessage(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
853
    let originalMethod = descriptor.value;
16✔
854

855
    //wrapping the original method
856
    descriptor.value = function value(...args: any[]) {
16✔
857
        try {
108✔
858
            let result = originalMethod.apply(this, args);
108✔
859
            //if the result looks like a promise, log if there's a rejection
860
            if (result?.then) {
108!
861
                return Promise.resolve(result).catch((e: Error) => {
107✔
UNCOV
862
                    if (e?.stack) {
×
UNCOV
863
                        e.message = e.stack;
×
864
                    }
UNCOV
865
                    return Promise.reject(e);
×
866
                });
867
            } else {
868
                return result;
1✔
869
            }
870
        } catch (e: any) {
UNCOV
871
            if (e?.stack) {
×
872
                e.message = e.stack;
×
873
            }
UNCOV
874
            throw e;
×
875
        }
876
    };
877
}
878

879
type Handler<T> = {
880
    [K in keyof T as K extends `on${string}` ? K : never]:
881
    T[K] extends (arg: infer U) => void ? (arg: U) => void : never;
882
};
883
// Extracts the argument type from the function and constructs the desired interface
884
export type OnHandler<T> = {
885
    [K in keyof Handler<T>]: Handler<T>[K] extends (arg: infer U) => void ? U : never;
886
};
887

888
export interface BrightScriptProjectConfiguration {
889
    name?: string;
890
    path: string;
891
    disabled?: boolean;
892
}
893

894
export interface BrightScriptClientConfiguration {
895
    projects?: (string | BrightScriptProjectConfiguration)[];
896
    languageServer: {
897
        enableThreading: boolean;
898
        enableProjectDiscovery: boolean;
899
        projectDiscoveryExclude?: Record<string, boolean>;
900
        logLevel: LogLevel | string;
901
        projectDiscoveryMaxDepth?: number;
902
    };
903
}
904

905
function logAndIgnoreError(error: Error) {
906
    if (error?.stack) {
2!
907
        error.message = error.stack;
2✔
908
    }
909
    console.error(error);
2✔
910
}
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