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

rokucommunity / brighterscript / #15903

11 May 2026 06:41PM UTC coverage: 86.896% (-2.2%) from 89.094%
#15903

push

web-flow
Merge 70dfd6181 into ce68f5cb7

15597 of 18958 branches covered (82.27%)

Branch coverage included in aggregate %.

9 of 9 new or added lines in 3 files covered. (100.0%)

955 existing lines in 53 files now uncovered.

16351 of 17808 relevant lines covered (91.82%)

27326.16 hits per line

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

88.41
/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
    SelectionRangeParams,
31
    RenameFilesParams,
32
    WorkspaceEdit,
33
    TextEdit
34
} from 'vscode-languageserver/node';
35
import {
1✔
36
    SemanticTokensRequest,
37
    createConnection,
38
    DidChangeConfigurationNotification,
39
    FileChangeType,
40
    ProposedFeatures,
41
    TextDocuments,
42
    TextDocumentSyncKind,
43
    CodeActionKind
44
} from 'vscode-languageserver/node';
45
import { URI } from 'vscode-uri';
1✔
46
import { TextDocument } from 'vscode-languageserver-textdocument';
1✔
47
import { util } from './util';
1✔
48
import { DiagnosticCollection } from './DiagnosticCollection';
1✔
49
import { encodeSemanticTokens, semanticTokensLegend } from './SemanticTokenUtils';
1✔
50
import { LogLevel, createLogger, logger, setLspLoggerProps } from './logging';
1✔
51
import ignore from 'ignore';
1✔
52
import * as micromatch from 'micromatch';
1✔
53
import type { LspProject, LspDiagnostic } from './lsp/LspProject';
54
import { PathFilterer } from './lsp/PathFilterer';
1✔
55
import type { WorkspaceConfig } from './lsp/ProjectManager';
56
import { ProjectManager } from './lsp/ProjectManager';
1✔
57
import * as fsExtra from 'fs-extra';
1✔
58
import type { FileChange, MaybePromise } from './interfaces';
59
import { Deferred } from './deferred';
1✔
60
import { workerPool } from './lsp/worker/WorkerThreadProject';
1✔
61
// eslint-disable-next-line @typescript-eslint/no-require-imports
62
import isEqual = require('lodash.isequal');
1✔
63

64
export class LanguageServer {
1✔
65
    /**
66
     * The default threading setting for the language server. Can be overridden by per-workspace settings
67
     */
68
    public static enableThreadingDefault = true;
1✔
69
    /**
70
     * The default project discovery setting for the language server. Can be overridden by per-workspace settings
71
     */
72
    public static enableProjectDiscoveryDefault = true;
1✔
73

74
    /**
75
     * The default number of projects that are permitted to activate concurrently.
76
     */
77
    private static projectActivationConcurrencyLimitDefault = 3;
1✔
78

79
    /**
80
     * The language server protocol connection, used to send and receive all requests and responses
81
     */
82
    private connection = undefined as Connection;
95✔
83

84
    /**
85
     * Manages all projects for this language server
86
     */
87
    private projectManager: ProjectManager;
88

89
    private hasConfigurationCapability = false;
95✔
90

91
    /**
92
     * Indicates whether the client supports workspace folders
93
     */
94
    private clientHasWorkspaceFolderCapability = false;
95✔
95

96
    /**
97
     * Create a simple text document manager.
98
     * The text document manager supports full document sync only
99
     */
100
    private documents = new TextDocuments(TextDocument);
95✔
101

102
    private loggerSubscription: () => void;
103

104
    /**
105
     * Used to filter paths based on include/exclude lists (like .gitignore or vscode's `files.exclude`).
106
     * This is used to prevent the language server from being overwhelmed by files we don't actually want to handle
107
     */
108
    private pathFilterer: PathFilterer;
109

110
    public logger = createLogger({
95✔
111
        logLevel: LogLevel.log
112
    });
113

114
    constructor() {
115
        setLspLoggerProps();
95✔
116
        //replace the workerPool logger with our own so logging info can be synced
117
        workerPool.logger = this.logger.createLogger();
95✔
118

119
        this.pathFilterer = new PathFilterer({ logger: this.logger });
95✔
120

121
        this.projectManager = new ProjectManager({
95✔
122
            pathFilterer: this.pathFilterer,
123
            logger: this.logger.createLogger()
124
        });
125

126
        //anytime a project emits a collection of diagnostics, send them to the client
127
        this.projectManager.on('diagnostics', (event) => {
95✔
128
            this.logger.debug(`Received ${event.diagnostics.length} diagnostics from project ${event.project.projectNumber}`);
73✔
129
            this.sendDiagnostics(event).catch(logAndIgnoreError);
73✔
130
        });
131

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

136
        this.projectManager.on('project-activate', (event) => {
95✔
137
            //keep logLevel in sync with the most verbose log level found across all projects
138
            this.syncLogLevel().catch(logAndIgnoreError);
55✔
139

140
            //resend all open document changes
141
            const documents = [...this.documents.all()];
55✔
142
            if (documents.length > 0) {
55✔
143
                this.logger.log(`[${util.getProjectLogName(event.project)}] loaded or changed. Resending all open document changes.`, documents.map(x => x.uri));
47✔
144
                for (const document of this.documents.all()) {
23✔
145
                    this.onTextDocumentDidChangeContent({
47✔
146
                        document: document
147
                    }).catch(logAndIgnoreError);
148
                }
149
            }
150
        });
151

152
        this.projectManager.busyStatusTracker.on('active-runs-change', (event) => {
95✔
153
            this.sendBusyStatus();
632✔
154
        });
155
    }
156

157
    //run the server
158
    public run() {
159
        // Create a connection for the server. The connection uses Node's IPC as a transport.
160
        this.connection = this.establishConnection();
36✔
161

162
        //disable logger colors when running in LSP mode
163
        logger.enableColor = false;
36✔
164

165
        //listen to all of the output log events and pipe them into the debug channel in the extension
166
        this.loggerSubscription = logger.subscribe((message) => {
36✔
167
            this.connection.tracer.log(message.argsText);
305✔
168
        });
169

170
        //bind all our on* methods that share the same name from connection
171
        for (const name of Object.getOwnPropertyNames(LanguageServer.prototype)) {
36✔
172
            if (/on+/.test(name) && typeof this.connection?.[name] === 'function') {
1,260!
173
                this.connection[name](this[name].bind(this));
504✔
174
            }
175
        }
176

177
        //Register semantic token requests. TODO switch to a more specific connection function call once they actually add it
178
        this.connection.onRequest(SemanticTokensRequest.method, this.onFullSemanticTokens.bind(this));
36✔
179

180
        //file-operation requests live under connection.workspace, so they aren't picked up by the on* auto-bind loop above
181
        this.connection.workspace.onWillRenameFiles(this.onWillRenameFiles.bind(this));
36✔
182

183
        // The content of a text document has changed. This event is emitted
184
        // when the text document is first opened, when its content has changed,
185
        // or when document is closed without saving (original contents are sent as a change)
186
        //
187
        this.documents.onDidChangeContent(this.onTextDocumentDidChangeContent.bind(this));
36✔
188

189
        //whenever a document gets closed
190
        this.documents.onDidClose(this.onDocumentClose.bind(this));
36✔
191

192
        // listen for open, change and close text document events
193
        this.documents.listen(this.connection);
36✔
194

195
        // Listen on the connection
196
        this.connection.listen();
36✔
197
    }
198

199
    /**
200
     * Called when the client starts initialization
201
     */
202
    @AddStackToErrorMessage
203
    public onInitialize(params: InitializeParams): HandlerResult<InitializeResult, InitializeError> {
1✔
204
        let clientCapabilities = params.capabilities;
3✔
205

206
        // Does the client support the `workspace/configuration` request?
207
        // If not, we will fall back using global settings
208
        this.hasConfigurationCapability = !!(clientCapabilities.workspace && !!clientCapabilities.workspace.configuration);
3✔
209
        this.clientHasWorkspaceFolderCapability = !!(clientCapabilities.workspace && !!clientCapabilities.workspace.workspaceFolders);
3✔
210

211
        //return the capabilities of the server
212
        return {
3✔
213
            capabilities: {
214
                textDocumentSync: TextDocumentSyncKind.Full,
215
                // Tell the client that the server supports code completion
216
                completionProvider: {
217
                    resolveProvider: false,
218
                    //anytime the user types a period, auto-show the completion results
219
                    triggerCharacters: ['.'],
220
                    allCommitCharacters: ['.', '@']
221
                },
222
                documentSymbolProvider: true,
223
                workspaceSymbolProvider: true,
224
                semanticTokensProvider: {
225
                    legend: semanticTokensLegend,
226
                    full: true
227
                } as SemanticTokensOptions,
228
                referencesProvider: true,
229
                codeActionProvider: {
230
                    codeActionKinds: [
231
                        CodeActionKind.QuickFix,
232
                        CodeActionKind.Refactor,
233
                        CodeActionKind.SourceFixAll
234
                    ]
235
                },
236
                signatureHelpProvider: {
237
                    triggerCharacters: ['(', ',']
238
                },
239
                definitionProvider: true,
240
                hoverProvider: true,
241
                selectionRangeProvider: true,
242
                executeCommandProvider: {
243
                    commands: [
244
                        CustomCommands.TranspileFile
245
                    ]
246
                },
247
                workspace: {
248
                    fileOperations: {
249
                        willRename: {
250
                            filters: [{
251
                                pattern: {
252
                                    glob: '**/*.{bs,brs,xml}',
253
                                    matches: 'file'
254
                                }
255
                            }]
256
                        }
257
                    }
258
                }
259
            } as ServerCapabilities
260
        };
261
    }
262

263
    /**
264
     * Called when the client has finished initializing
265
     */
266
    @AddStackToErrorMessage
267
    public async onInitialized() {
1✔
268
        this.logger.log('onInitialized');
12✔
269

270
        //cache a copy of all workspace configurations to use for comparison later
271
        this.workspaceConfigsCache = new Map(
12✔
272
            (await this.getWorkspaceConfigs()).map(x => [x.workspaceFolder, x])
12✔
273
        );
274

275
        //set our logger to the most verbose logLevel found across any project
276
        await this.syncLogLevel();
12✔
277

278
        this.syncProjectActivationConcurrencyLimit();
12✔
279

280
        try {
12✔
281
            if (this.hasConfigurationCapability) {
12✔
282
                // register for when the user changes workspace or user settings
283
                await this.connection.client.register(
10✔
284
                    DidChangeConfigurationNotification.type,
285
                    {
286
                        //we only care about when these settings sections change
287
                        section: [
288
                            'brightscript',
289
                            'files'
290
                        ]
291
                    } as DidChangeConfigurationRegistrationOptions
292
                );
293
            }
294

295
            //populate the path filterer with the client's include/exclude lists
296
            await this.rebuildPathFilterer();
12✔
297

298
            await this.syncProjects();
12✔
299

300
            if (this.clientHasWorkspaceFolderCapability) {
12✔
301
                //if the client changes their workspaces, we need to get our projects in sync
302
                this.connection.workspace.onDidChangeWorkspaceFolders(async (evt) => {
1✔
303
                    await this.syncProjects();
×
304
                });
305
            }
306
        } catch (e: any) {
307
            this.sendCriticalFailure(
×
308
                `Critical failure during BrighterScript language server startup.
309
                Please file a github issue and include the contents of the 'BrighterScript Language Server' output channel.
310

311
                Error message: ${e.message}`
312
            );
313
            throw e;
×
314
        }
315
    }
316

317
    /**
318
     * Set our logLevel to the most verbose log level found across all projects and workspaces
319
     */
320
    private async syncLogLevel() {
321
        /**
322
         * helper to get the logLevel from a list of items and return the item and level (if found), or undefined if not
323
         */
324
        const getLogLevel = async<T>(
117✔
325
            items: T[],
326
            fetcher: (item: T) => MaybePromise<LogLevel | string>
327
        ): Promise<{ logLevel: LogLevel; logLevelText: string; item: T }> => {
328
            const logLevels = await Promise.all(
227✔
329
                items.map(async (item) => {
330
                    let value = await fetcher(item);
245✔
331
                    //force string values to lower case (so we can support things like 'log' or 'Log' or 'LOG')
332
                    if (typeof value === 'string') {
245✔
333
                        value = value.toLowerCase();
14✔
334
                    }
335
                    const logLevelNumeric = this.logger.getLogLevelNumeric(value as any);
245✔
336

337
                    if (typeof logLevelNumeric === 'number') {
245✔
338
                        return logLevelNumeric;
131✔
339
                    } else {
340
                        return -1;
114✔
341
                    }
342
                })
343
            );
344
            let idx = logLevels.findIndex(x => x > -1);
227✔
345
            if (idx > -1) {
227✔
346
                const mostVerboseLogLevel = Math.max(...logLevels);
102✔
347
                return {
102✔
348
                    logLevel: mostVerboseLogLevel,
349
                    logLevelText: this.logger.getLogLevelText(mostVerboseLogLevel),
350
                    //find the first item having the most verbose logLevel
351
                    item: items[logLevels.findIndex(x => x === mostVerboseLogLevel)]
103✔
352
                };
353
            }
354
        };
355

356
        const workspaces = await this.getWorkspaceConfigs();
117✔
357

358
        let workspaceResult = await getLogLevel(workspaces, workspace => workspace?.languageServer?.logLevel);
122!
359

360
        if (workspaceResult) {
117✔
361
            this.logger.info(`Setting global logLevel to '${workspaceResult.logLevelText}' based on configuration from workspace '${workspaceResult?.item?.workspaceFolder}'`);
7!
362
            this.logger.logLevel = workspaceResult.logLevel;
7✔
363
            return;
7✔
364
        }
365

366
        let projectResult = await getLogLevel(this.projectManager.projects, (project) => project.logger.logLevel);
123✔
367
        if (projectResult) {
110✔
368
            this.logger.info(`Setting global logLevel to '${projectResult.logLevelText}' based on project #${projectResult?.item?.projectNumber}`);
95!
369
            this.logger.logLevel = projectResult.logLevel;
95✔
370
            return;
95✔
371
        }
372

373
        //use a default level if no other level was found
374
        this.logger.logLevel = LogLevel.log;
15✔
375
    }
376

377
    /**
378
     * Get the project activation concurrency limit from all workspaces and set the project manager's concurrency limit to the lowest value found.
379
     * This ensures that if the user has multiple workspaces open with different limits,
380
     * we respect the most restrictive limit to avoid overwhelming the user's machine.
381
     */
382
    private syncProjectActivationConcurrencyLimit() {
383
        const limits = [...this.workspaceConfigsCache]
30✔
384
            .map(x => x?.[1]?.languageServer?.projectActivationConcurrencyLimit)
32!
385
            .filter(x => typeof x === 'number');
32✔
386

387
        //if we don't have any limits defined, use our default value
388
        if (limits.length === 0) {
30✔
389
            limits.push(LanguageServer.projectActivationConcurrencyLimitDefault);
19✔
390
        }
391

392
        let concurrencyLimit = Math.min(...limits);
30✔
393
        //we must always at least support 1 project activating at a time, otherwise no projects would ever activate
394
        if (!(concurrencyLimit >= 1)) {
30✔
395
            this.logger.log(`projectActivationConcurrencyLimit was set to ${concurrencyLimit}, which is not a valid value. Defaulting to 1.`);
3✔
396
            concurrencyLimit = 1;
3✔
397
        }
398
        this.projectManager.projectActivationConcurrencyLimit = concurrencyLimit;
30✔
399
    }
400

401

402
    @AddStackToErrorMessage
403
    private async onTextDocumentDidChangeContent(event: TextDocumentChangeEvent<TextDocument>) {
1✔
404
        this.logger.debug('onTextDocumentDidChangeContent', event.document.uri);
49✔
405

406
        await this.projectManager.handleFileChanges([{
49✔
407
            srcPath: URI.parse(event.document.uri).fsPath,
408
            type: FileChangeType.Changed,
409
            fileContents: event.document.getText(),
410
            allowStandaloneProject: true
411
        }]);
412
    }
413

414
    /**
415
     * Pending file changes waiting to be flushed after the debounce period
416
     */
417
    private pendingFileChanges: FileChange[] = [];
95✔
418

419
    /**
420
     * Timer handle for the file change debounce
421
     */
422
    private fileChangeDebounceTimer: ReturnType<typeof setTimeout> | undefined;
423

424
    /**
425
     * How long to wait (in ms) after the last file change event before processing the batch.
426
     * This prevents excessive revalidation during bulk operations like `git checkout` or package installs.
427
     */
428
    public fileChangeDebounceDelay = 300;
95✔
429

430
    /**
431
     * Called when watched files changed (add/change/delete).
432
     * The CLIENT is in charge of what files to watch, so all client
433
     * implementations should ensure that all valid project
434
     * file types are watched (.brs,.bs,.xml,manifest, and any json/text/image files)
435
     *
436
     * File changes are debounced to batch rapid successive events (e.g. during builds or VCS operations)
437
     * into a single processing pass, reducing redundant work across projects.
438
     */
439
    @AddStackToErrorMessage
440
    public async onDidChangeWatchedFiles(params: DidChangeWatchedFilesParams) {
1✔
441
        const workspacePaths = (await this.connection.workspace.getWorkspaceFolders()).map(x => util.uriToPath(x.uri));
23✔
442

443
        const changes = params.changes
23✔
444
            .map(x => ({
23✔
445
                srcPath: util.uriToPath(x.uri),
446
                type: x.type,
447
                //if this is an open document, allow this file to be loaded in a standalone project (if applicable)
448
                allowStandaloneProject: this.documents.get(x.uri) !== undefined
449
            }))
450
            //exclude all explicit top-level workspace folder paths (to fix a weird macos fs watcher bug that emits events for the workspace folder itself)
451
            .filter(x => !workspacePaths.includes(x.srcPath));
23✔
452

453
        this.logger.debug('onDidChangeWatchedFiles', changes);
23✔
454

455
        //accumulate changes into the pending buffer
456
        this.pendingFileChanges.push(...changes);
23✔
457

458
        //reset the debounce timer so we batch rapid successive events
459
        clearTimeout(this.fileChangeDebounceTimer);
23✔
460

461
        //use a deferred so callers can await the completion of the flush
462
        if (!this.pendingFileChangesDeferred) {
23✔
463
            this.pendingFileChangesDeferred = new Deferred();
16✔
464
        }
465
        const deferred = this.pendingFileChangesDeferred;
23✔
466

467
        this.fileChangeDebounceTimer = setTimeout(() => {
23✔
468
            void this.flushFileChanges().then(
16✔
469
                () => deferred.resolve(),
16✔
470
                (err) => deferred.reject(err)
×
471
            );
472
        }, this.fileChangeDebounceDelay);
473

474
        return deferred.promise;
23✔
475
    }
476

477
    /**
478
     * Deferred for the current pending file changes batch
479
     */
480
    private pendingFileChangesDeferred: Deferred | undefined;
481

482
    /**
483
     * Flush all pending file changes accumulated during the debounce window
484
     */
485
    private async flushFileChanges() {
486
        //grab all pending changes and clear the buffer, deduping by srcPath (last event wins)
487
        const deduped = new Map<string, FileChange>();
16✔
488
        for (const change of this.pendingFileChanges.splice(0, this.pendingFileChanges.length)) {
16✔
489
            deduped.set(change.srcPath, change);
22✔
490
        }
491
        const changes = [...deduped.values()];
16✔
492
        this.pendingFileChangesDeferred = undefined;
16✔
493

494
        //if the client changed any files containing include/exclude patterns, rebuild the path filterer before processing these changes
495
        if (
16✔
496
            micromatch.some(changes.map(x => x.srcPath), [
19✔
497
                '**/.gitignore',
498
                '**/.vscode/settings.json',
499
                '**/*bsconfig*.json'
500
            ], {
501
                dot: true
502
            })
503
        ) {
504
            await this.rebuildPathFilterer();
8✔
505
        }
506

507
        //handle the file changes
508
        await this.projectManager.handleFileChanges(changes);
16✔
509
    }
510

511
    @AddStackToErrorMessage
512
    private async onDocumentClose(event: TextDocumentChangeEvent<TextDocument>): Promise<void> {
1✔
513
        this.logger.debug('onDocumentClose', event.document.uri);
1✔
514

515
        await this.projectManager.handleFileClose({
1✔
516
            srcPath: util.uriToPath(event.document.uri)
517
        });
518
    }
519

520
    /**
521
     * Provide a list of completion items based on the current cursor position
522
     */
523
    @AddStackToErrorMessage
524
    public async onCompletion(params: CompletionParams, cancellationToken?: CancellationToken, workDoneProgress?: WorkDoneProgressReporter, resultProgress?: ResultProgressReporter<CompletionItem[]>): Promise<CompletionList> {
1✔
525
        this.logger.debug('onCompletion', params, cancellationToken);
2✔
526

527
        const srcPath = util.uriToPath(params.textDocument.uri);
2✔
528
        const completions = await this.projectManager.getCompletions({
2✔
529
            srcPath: srcPath,
530
            position: params.position,
531
            cancellationToken: cancellationToken
532
        });
533
        return completions;
2✔
534
    }
535

536
    /**
537
     * Get a list of workspaces, and their configurations.
538
     * 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.
539
     */
540
    private async getWorkspaceConfigs(): Promise<WorkspaceConfig[]> {
541
        //get all workspace folders (we'll use these to get settings)
542
        let workspaces = await Promise.all(
188✔
543
            (await this.connection.workspace.getWorkspaceFolders() ?? []).map(async (x) => {
564!
544
                const workspaceFolder = util.uriToPath(x.uri);
195✔
545
                const brightscriptConfig = await this.getClientConfiguration<BrightScriptClientConfiguration>(x.uri, 'brightscript');
195✔
546
                return {
195✔
547
                    workspaceFolder: workspaceFolder,
548
                    excludePatterns: await this.getWorkspaceExcludeGlobs(workspaceFolder),
549
                    projects: this.normalizeProjectPaths(workspaceFolder, brightscriptConfig?.projects),
584✔
550
                    languageServer: {
551
                        enableThreading: brightscriptConfig?.languageServer?.enableThreading ?? LanguageServer.enableThreadingDefault,
1,754✔
552
                        enableProjectDiscovery: brightscriptConfig?.languageServer?.enableProjectDiscovery ?? LanguageServer.enableProjectDiscoveryDefault,
1,754✔
553
                        projectDiscoveryMaxDepth: brightscriptConfig?.languageServer?.projectDiscoveryMaxDepth ?? 15,
1,754!
554
                        projectDiscoveryExclude: brightscriptConfig?.languageServer?.projectDiscoveryExclude,
1,169✔
555
                        logLevel: brightscriptConfig?.languageServer?.logLevel,
1,169✔
556
                        projectActivationConcurrencyLimit: brightscriptConfig?.languageServer?.projectActivationConcurrencyLimit
1,169✔
557
                    }
558
                };
559
            })
560
        );
561
        return workspaces;
188✔
562
    }
563

564
    /**
565
     * Extract project paths from settings' projects list, expanding the workspaceFolder variable if necessary
566
     */
567
    private normalizeProjectPaths(workspaceFolder: string, projects: (string | BrightScriptProjectConfiguration)[]): BrightScriptProjectConfiguration[] | undefined {
568
        return projects?.reduce((acc, project) => {
195✔
569
            if (typeof project === 'string') {
3✔
570
                acc.push({ path: project });
2✔
571
            } else if (typeof project.path === 'string') {
1!
572
                acc.push(project);
1✔
573
            }
574
            return acc;
3✔
575
        }, []).map(project => ({
3✔
576
            ...project,
577
            // eslint-disable-next-line no-template-curly-in-string
578
            path: util.standardizePath(project.path.replace('${workspaceFolder}', workspaceFolder))
579
        }));
580
    }
581

582
    private workspaceConfigsCache = new Map<string, WorkspaceConfig>();
95✔
583

584
    @AddStackToErrorMessage
585
    public async onDidChangeConfiguration(args: DidChangeConfigurationParams) {
1✔
586
        this.logger.log('onDidChangeConfiguration', 'Reloading all projects');
6✔
587

588
        const configs = new Map(
6✔
589
            (await this.getWorkspaceConfigs()).map(x => [x.workspaceFolder, x])
5✔
590
        );
591

592
        //find any changed configs. This includes newly created workspaces, deleted workspaces, etc.
593
        //TODO: enhance this to only reload specific projects, depending on the change
594
        if (!isEqual(configs, this.workspaceConfigsCache)) {
6✔
595
            //now that we've processed any config diffs, update the cached copy of them
596
            this.workspaceConfigsCache = configs;
4✔
597

598
            this.syncProjectActivationConcurrencyLimit();
4✔
599

600
            //if configuration changed, rebuild the path filterer
601
            await this.rebuildPathFilterer();
4✔
602

603
            //if the user changes any user/workspace config settings, just mass-reload all projects
604
            await this.syncProjects(true);
4✔
605
        }
606
    }
607

608

609
    @AddStackToErrorMessage
610
    public async onHover(params: TextDocumentPositionParams) {
1✔
611
        this.logger.debug('onHover', params);
×
612

613
        const srcPath = util.uriToPath(params.textDocument.uri);
×
614
        const result = await this.projectManager.getHover({ srcPath: srcPath, position: params.position });
×
615
        return result;
×
616
    }
617

618
    @AddStackToErrorMessage
619
    public async onWorkspaceSymbol(params: WorkspaceSymbolParams) {
1✔
620
        this.logger.debug('onWorkspaceSymbol', params);
4✔
621

622
        const result = await this.projectManager.getWorkspaceSymbol();
4✔
623
        return result;
4✔
624
    }
625

626
    @AddStackToErrorMessage
627
    public async onSelectionRanges(params: SelectionRangeParams) {
1✔
628
        this.logger.debug('onSelectionRanges', params);
×
629

630
        const srcPath = util.uriToPath(params.textDocument.uri);
×
631
        return this.projectManager.getSelectionRanges({ srcPath: srcPath, positions: params.positions });
×
632
    }
633

634
    @AddStackToErrorMessage
635
    public async onDocumentSymbol(params: DocumentSymbolParams) {
1✔
636
        this.logger.debug('onDocumentSymbol', params);
6✔
637

638
        const srcPath = util.uriToPath(params.textDocument.uri);
6✔
639
        const result = await this.projectManager.getDocumentSymbol({ srcPath: srcPath });
6✔
640
        return result;
6✔
641
    }
642

643
    @AddStackToErrorMessage
644
    public async onDefinition(params: TextDocumentPositionParams) {
1✔
645
        this.logger.debug('onDefinition', params);
5✔
646

647
        const srcPath = util.uriToPath(params.textDocument.uri);
5✔
648

649
        const result = this.projectManager.getDefinition({ srcPath: srcPath, position: params.position });
5✔
650
        return result;
5✔
651
    }
652

653
    @AddStackToErrorMessage
654
    public async onSignatureHelp(params: SignatureHelpParams) {
1✔
655
        this.logger.debug('onSignatureHelp', params);
4✔
656

657
        const srcPath = util.uriToPath(params.textDocument.uri);
4✔
658
        const result = await this.projectManager.getSignatureHelp({ srcPath: srcPath, position: params.position });
4✔
659
        if (result) {
4✔
660
            return result;
3✔
661
        } else {
662
            return {
1✔
663
                signatures: [],
664
                activeSignature: null,
665
                activeParameter: null
666
            };
667
        }
668

669
    }
670

671
    @AddStackToErrorMessage
672
    public async onReferences(params: ReferenceParams) {
1✔
673
        this.logger.debug('onReferences', params);
3✔
674

675
        const srcPath = util.uriToPath(params.textDocument.uri);
3✔
676
        const result = await this.projectManager.getReferences({ srcPath: srcPath, position: params.position });
3✔
677
        return result ?? [];
3!
678
    }
679

680
    @AddStackToErrorMessage
681
    public async onWillRenameFiles(params: RenameFilesParams): Promise<WorkspaceEdit | null> {
1✔
682
        this.logger.debug('onWillRenameFiles', params);
2✔
683

684
        const changes: Record<string, TextEdit[]> = {};
2✔
685
        for (const file of params.files ?? []) {
2!
686
            const oldSrcPath = util.uriToPath(file.oldUri);
2✔
687
            const newSrcPath = util.uriToPath(file.newUri);
2✔
688
            const edits = await this.projectManager.getFileRenameEdits({ oldSrcPath: oldSrcPath, newSrcPath: newSrcPath });
2✔
689
            for (const edit of edits) {
2✔
690
                (changes[edit.uri] ??= []).push({
1!
691
                    range: edit.range,
692
                    newText: edit.newText
693
                });
694
            }
695
        }
696

697
        if (Object.keys(changes).length === 0) {
2✔
698
            return null;
1✔
699
        }
700
        return { changes: changes };
1✔
701
    }
702

703

704
    @AddStackToErrorMessage
705
    private async onFullSemanticTokens(params: SemanticTokensParams) {
1✔
706
        this.logger.debug('onFullSemanticTokens', params);
1✔
707

708
        const srcPath = util.uriToPath(params.textDocument.uri);
1✔
709
        const result = await this.projectManager.getSemanticTokens({ srcPath: srcPath });
1✔
710

711
        return {
1✔
712
            data: encodeSemanticTokens(result)
713
        } as SemanticTokens;
714
    }
715

716
    @AddStackToErrorMessage
717
    public async onCodeAction(params: CodeActionParams) {
1✔
718
        this.logger.debug('onCodeAction', params);
7✔
719

720
        const srcPath = util.uriToPath(params.textDocument.uri);
7✔
721
        const requestedKinds = params.context?.only ?? [];
7!
722
        const wantsAnyKind = requestedKinds.length === 0;
7✔
723

724
        // Fix-all is opt-in and expensive, so only fetch when the client asks for it.
725
        const fixAllKind = CodeActionKind.SourceFixAll;
7✔
726
        const wantsFixAll = wantsAnyKind ||
7✔
727
            requestedKinds.some(kind => kind.startsWith(fixAllKind) || fixAllKind.startsWith(kind));
6✔
728

729
        // Standard actions (quickfix, refactor, etc.) all come through getCodeActions,
730
        // so only skip that pipeline when the client explicitly asked for fix-all only.
731
        const wantsStandardActions = wantsAnyKind ||
7✔
732
            requestedKinds.some(kind => !kind.startsWith(fixAllKind));
5✔
733

734
        const [standardActions, fixAllActions] = await Promise.all([
7✔
735
            wantsStandardActions ? this.projectManager.getCodeActions({ srcPath: srcPath, range: params.range }) : [],
7!
736
            wantsFixAll ? this.projectManager.getFixAllCodeActions({ srcPath: srcPath }) : []
7✔
737
        ]);
738

739
        const result = [...(standardActions ?? []), ...(fixAllActions ?? [])];
7!
740

741
        // filter out any code actions with a kind that the client did not ask for (if the client specified any kinds at all)
742
        if (!wantsAnyKind) {
7✔
743
            return result.filter(x => x.kind && requestedKinds.some(only => x.kind === only || x.kind.startsWith(only + '.')));
16✔
744
        }
745
        return result;
2✔
746
    }
747

748

749
    @AddStackToErrorMessage
750
    public async onExecuteCommand(params: ExecuteCommandParams) {
1✔
751
        this.logger.debug('onExecuteCommand', params);
2✔
752

753
        if (params.command === CustomCommands.TranspileFile) {
2!
754
            const args = {
2✔
755
                srcPath: params.arguments[0] as string
756
            };
757
            const result = await this.projectManager.transpileFile(args);
2✔
758
            //back-compat: include `pathAbsolute` property so older vscode versions still work
759
            (result as any).pathAbsolute = result.srcPath;
2✔
760
            return result;
2✔
761
        }
762
    }
763

764
    /**
765
     * Establish a connection to the client if not already connected
766
     */
767
    private establishConnection() {
768
        if (!this.connection) {
×
769
            this.connection = createConnection(ProposedFeatures.all);
×
770
        }
771
        return this.connection;
×
772
    }
773

774
    /**
775
     * Send a new busy status notification to the client based on the current busy status
776
     */
777
    private sendBusyStatus() {
778
        this.busyStatusIndex = ++this.busyStatusIndex <= 0 ? 0 : this.busyStatusIndex;
632✔
779

780
        this.connection.sendNotification(NotificationName.busyStatus, {
632!
781
            status: this.projectManager.busyStatusTracker.status,
782
            timestamp: Date.now(),
783
            index: this.busyStatusIndex,
784
            activeRuns: [
785
                //extract only specific information from the active run so we know what's going on
786
                ...this.projectManager.busyStatusTracker.activeRuns.map(x => ({
821✔
787
                    scope: util.getProjectLogName(x.scope),
788
                    label: x.label,
789
                    startTime: x.startTime.getTime()
790
                }))
791
            ]
792
        })?.catch(logAndIgnoreError);
632!
793
    }
794
    private busyStatusIndex = -1;
95✔
795

796
    private pathFiltererDisposables: Array<() => void> = [];
95✔
797

798
    /**
799
     * Populate the path filterer with the client's include/exclude lists and the projects include lists
800
     * @returns the instance of the path filterer
801
     */
802
    private async rebuildPathFilterer() {
803
        //dispose of any previous pathFilterer disposables
804
        this.pathFiltererDisposables?.forEach(dispose => dispose());
26!
805
        //keep track of all the pathFilterer disposables so we can dispose them later
806
        this.pathFiltererDisposables = [];
26✔
807

808
        const workspaceConfigs = await this.getWorkspaceConfigs();
26✔
809
        await Promise.all(workspaceConfigs.map(async (workspaceConfig) => {
26✔
810
            const rootDir = util.uriToPath(workspaceConfig.workspaceFolder);
27✔
811

812
            //always exclude everything from these common folders
813
            this.pathFiltererDisposables.push(
27✔
814
                this.pathFilterer.registerExcludeList(rootDir, [
815
                    '**/node_modules/**/*',
816
                    '**/.git/**/*',
817
                    'out/**/*',
818
                    '**/.roku-deploy-staging/**/*'
819
                ])
820
            );
821
            //get any `files.exclude` patterns from the client from this workspace
822
            this.pathFiltererDisposables.push(
27✔
823
                this.pathFilterer.registerExcludeList(rootDir, workspaceConfig.excludePatterns)
824
            );
825

826
            //get any .gitignore patterns from the client from this workspace
827
            const gitignorePath = path.resolve(rootDir, '.gitignore');
27✔
828
            if (await fsExtra.pathExists(gitignorePath)) {
27✔
829
                const matcher = ignore({ ignoreCase: true }).add(
4✔
830
                    fsExtra.readFileSync(gitignorePath).toString()
831
                );
832
                this.pathFiltererDisposables.push(
4✔
833
                    this.pathFilterer.registerExcludeMatcher((p: string) => {
834
                        const relPath = path.relative(rootDir, p);
8✔
835
                        if (ignore.isPathValid(relPath)) {
8✔
836
                            return matcher.test(relPath).ignored;
5✔
837
                        } else {
838
                            //we do not have a valid relative path, so we cannot determine if it is ignored...thus it is NOT ignored
839
                            return false;
3✔
840
                        }
841
                    })
842
                );
843
            }
844
        }));
845
        this.logger.log('pathFilterer successfully reconstructed');
26✔
846

847
        return this.pathFilterer;
26✔
848
    }
849

850
    /**
851
     * Ask the client for the list of `files.exclude` and `files.watcherExclude` patterns. Useful when determining if we should process a file
852
     */
853
    private async getWorkspaceExcludeGlobs(workspaceFolder: string): Promise<string[]> {
854
        const filesConfig = await this.getClientConfiguration<{ exclude: Record<string, boolean>; watcherExclude: Record<string, boolean> }>(workspaceFolder, 'files');
199✔
855
        const searchConfig = await this.getClientConfiguration<{ exclude: Record<string, boolean> }>(workspaceFolder, 'search');
199✔
856
        const languageServerConfig = await this.getClientConfiguration<BrightScriptClientConfiguration>(workspaceFolder, 'brightscript');
199✔
857

858
        return [
199✔
859
            ...this.extractExcludes(filesConfig?.exclude),
595✔
860
            ...this.extractExcludes(filesConfig?.watcherExclude),
595✔
861
            ...this.extractExcludes(searchConfig?.exclude),
595✔
862
            ...this.extractExcludes(languageServerConfig?.languageServer?.projectDiscoveryExclude)
1,192✔
863
        ];
864
    }
865

866
    private extractExcludes(exclude: Record<string, boolean>): string[] {
867
        //if the exclude is not defined, return an empty array
868
        if (!exclude) {
796✔
869
            return [];
771✔
870
        }
871
        return Object
25✔
872
            .keys(exclude)
873
            .filter(x => exclude[x])
29✔
874
            //vscode files.exclude patterns support ignoring folders without needing to add `**/*`. So for our purposes, we need to
875
            //append **/* to everything without a file extension or magic at the end
876
            .map(pattern => {
877
                const result = [
29✔
878
                    //send the pattern as-is (this handles weird cases and exact file matches)
879
                    pattern
880
                ];
881
                //treat the pattern as a directory (no harm in doing this because if it's a file, the pattern will just never match anything)
882
                if (!pattern.endsWith('/**/*')) {
29✔
883
                    result.push(`${pattern}/**/*`);
24✔
884
                }
885
                return result;
29✔
886
            })
887
            .flat(1);
888
    }
889

890
    /**
891
     * Ask the project manager to sync all projects found within the list of workspaces
892
     * @param forceReload if true, all projects are discarded and recreated from scratch
893
     */
894
    private async syncProjects(forceReload = false) {
45✔
895
        const workspaces = await this.getWorkspaceConfigs();
45✔
896

897
        await this.projectManager.syncProjects(workspaces, forceReload);
45✔
898

899
        //set our logLevel to the most verbose log level found across all projects and workspaces
900
        await this.syncLogLevel();
45✔
901
    }
902

903
    /**
904
     * Given a workspaceFolder path, get the specified configuration from the client (if applicable).
905
     * Be sure to use optional chaining to traverse the result in case that configuration doesn't exist or the client doesn't support `getConfiguration`
906
     * @param workspaceFolder the folder for the workspace in the client
907
     */
908
    private async getClientConfiguration<T extends Record<string, any>>(workspaceFolder: string, section: string): Promise<T> {
909
        const scopeUri = util.pathToUri(workspaceFolder);
722✔
910
        let config = {};
722✔
911

912
        //if the client supports configuration, look for config group called "brightscript"
913
        if (this.hasConfigurationCapability) {
722✔
914
            config = await this.connection.workspace.getConfiguration({
657✔
915
                scopeUri: scopeUri,
916
                section: section
917
            });
918
        }
919
        return config as T;
722✔
920
    }
921

922
    /**
923
     * Send a critical failure notification to the client, which should show a notification of some kind
924
     */
925
    private sendCriticalFailure(message: string) {
926
        this.connection.sendNotification('critical-failure', message).catch(logAndIgnoreError);
×
927
    }
928

929
    /**
930
     * Send diagnostics to the client
931
     */
932
    private async sendDiagnostics(options: { project: LspProject; diagnostics: LspDiagnostic[] }) {
933
        const patch = this.diagnosticCollection.getPatch(options.project.projectNumber, options.diagnostics);
73✔
934

935
        await Promise.all(Object.keys(patch).map(async (srcPath) => {
73✔
936
            const uri = URI.file(srcPath).toString();
17✔
937
            const diagnostics = patch[srcPath].map(d => util.toDiagnostic(d, uri));
17✔
938

939
            await this.connection.sendDiagnostics({
17✔
940
                uri: uri,
941
                diagnostics: diagnostics
942
            });
943
        }));
944
    }
945
    private diagnosticCollection = new DiagnosticCollection();
95✔
946

947
    protected dispose() {
948
        clearTimeout(this.fileChangeDebounceTimer);
95✔
949
        this.loggerSubscription?.();
95✔
950
        this.projectManager?.dispose?.();
95!
951
    }
952
}
953

954
export enum CustomCommands {
1✔
955
    TranspileFile = 'TranspileFile'
1✔
956
}
957

958
export enum NotificationName {
1✔
959
    busyStatus = 'busyStatus'
1✔
960
}
961

962
/**
963
 * Wraps a method. If there's an error (either sync or via a promise),
964
 * this appends the error's stack trace at the end of the error message so that the connection will
965
 */
966
function AddStackToErrorMessage(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
967
    let originalMethod = descriptor.value;
18✔
968

969
    //wrapping the original method
970
    descriptor.value = function value(...args: any[]) {
18✔
971
        try {
130✔
972
            let result = originalMethod.apply(this, args);
130✔
973
            //if the result looks like a promise, log if there's a rejection
974
            if (result?.then) {
130!
975
                return Promise.resolve(result).catch((e: Error) => {
127✔
976
                    if (e?.stack) {
×
977
                        e.message = e.stack;
×
978
                    }
979
                    return Promise.reject(e);
×
980
                });
981
            } else {
982
                return result;
3✔
983
            }
984
        } catch (e: any) {
985
            if (e?.stack) {
×
986
                e.message = e.stack;
×
987
            }
988
            throw e;
×
989
        }
990
    };
991
}
992

993
type Handler<T> = {
994
    [K in keyof T as K extends `on${string}` ? K : never]:
995
    T[K] extends (arg: infer U) => void ? (arg: U) => void : never;
996
};
997
// Extracts the argument type from the function and constructs the desired interface
998
export type OnHandler<T> = {
999
    [K in keyof Handler<T>]: Handler<T>[K] extends (arg: infer U) => void ? U : never;
1000
};
1001

1002
export interface BrightScriptProjectConfiguration {
1003
    name?: string;
1004
    path: string;
1005
    disabled?: boolean;
1006
}
1007

1008
export interface BrightScriptClientConfiguration {
1009
    projects?: (string | BrightScriptProjectConfiguration)[];
1010
    languageServer: {
1011
        enableThreading: boolean;
1012
        enableProjectDiscovery: boolean;
1013
        projectDiscoveryExclude?: Record<string, boolean>;
1014
        logLevel: LogLevel | string;
1015
        projectDiscoveryMaxDepth?: number;
1016
        projectActivationConcurrencyLimit?: number;
1017
    };
1018
}
1019

1020
function logAndIgnoreError(error: Error) {
UNCOV
1021
    if (error?.stack) {
×
UNCOV
1022
        error.message = error.stack;
×
1023
    }
UNCOV
1024
    console.error(error);
×
1025
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc