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

rokucommunity / brighterscript / #15048

01 Jan 2026 11:17PM UTC coverage: 87.048% (-0.9%) from 87.907%
#15048

push

web-flow
Merge 02ba2bb57 into 2ea4d2108

14498 of 17595 branches covered (82.4%)

Branch coverage included in aggregate %.

192 of 261 new or added lines in 12 files covered. (73.56%)

897 existing lines in 48 files now uncovered.

15248 of 16577 relevant lines covered (91.98%)

24112.76 hits per line

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

88.47
/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 { MaybePromise } from './interfaces';
55
import { workerPool } from './lsp/worker/WorkerThreadProject';
1✔
56
// eslint-disable-next-line @typescript-eslint/no-require-imports
57
import isEqual = require('lodash.isequal');
1✔
58

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

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

78
    private hasConfigurationCapability = false;
65✔
79

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

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

91
    private loggerSubscription: () => void;
92

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

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

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

108
        this.pathFilterer = new PathFilterer({ logger: this.logger });
65✔
109

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

265
            await this.syncProjects();
4✔
266

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

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

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

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

323
        const workspaces = await this.getWorkspaceConfigs();
92✔
324

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

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

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

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

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

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

356
    /**
357
     * Called when watched files changed (add/change/delete).
358
     * The CLIENT is in charge of what files to watch, so all client
359
     * implementations should ensure that all valid project
360
     * file types are watched (.brs,.bs,.xml,manifest, and any json/text/image files)
361
     */
362
    @AddStackToErrorMessage
363
    public async onDidChangeWatchedFiles(params: DidChangeWatchedFilesParams) {
1✔
364
        const workspacePaths = (await this.connection.workspace.getWorkspaceFolders()).map(x => util.uriToPath(x.uri));
11✔
365

366
        let changes = params.changes
11✔
367
            .map(x => ({
11✔
368
                srcPath: util.uriToPath(x.uri),
369
                type: x.type,
370
                //if this is an open document, allow this file to be loaded in a standalone project (if applicable)
371
                allowStandaloneProject: this.documents.get(x.uri) !== undefined
372
            }))
373
            //exclude all explicit top-level workspace folder paths (to fix a weird macos fs watcher bug that emits events for the workspace folder itself)
374
            .filter(x => !workspacePaths.includes(x.srcPath));
11✔
375

376
        this.logger.debug('onDidChangeWatchedFiles', changes);
11✔
377

378
        //if the client changed any files containing include/exclude patterns, rebuild the path filterer before processing these changes
379
        if (
11✔
380
            micromatch.some(changes.map(x => x.srcPath), [
10✔
381
                '**/.gitignore',
382
                '**/.vscode/settings.json',
383
                '**/*bsconfig*.json'
384
            ], {
385
                dot: true
386
            })
387
        ) {
388
            await this.rebuildPathFilterer();
7✔
389
        }
390

391
        //handle the file changes
392
        await this.projectManager.handleFileChanges(changes);
11✔
393
    }
394

395
    @AddStackToErrorMessage
396
    private async onDocumentClose(event: TextDocumentChangeEvent<TextDocument>): Promise<void> {
1✔
397
        this.logger.debug('onDocumentClose', event.document.uri);
1✔
398

399
        await this.projectManager.handleFileClose({
1✔
400
            srcPath: util.uriToPath(event.document.uri)
401
        });
402
    }
403

404
    /**
405
     * Provide a list of completion items based on the current cursor position
406
     */
407
    @AddStackToErrorMessage
408
    public async onCompletion(params: CompletionParams, cancellationToken?: CancellationToken, workDoneProgress?: WorkDoneProgressReporter, resultProgress?: ResultProgressReporter<CompletionItem[]>): Promise<CompletionList> {
1✔
409
        this.logger.debug('onCompletion', params, cancellationToken);
2✔
410

411
        const srcPath = util.uriToPath(params.textDocument.uri);
2✔
412
        const completions = await this.projectManager.getCompletions({
2✔
413
            srcPath: srcPath,
414
            position: params.position,
415
            cancellationToken: cancellationToken
416
        });
417
        return completions;
2✔
418
    }
419

420
    /**
421
     * Get a list of workspaces, and their configurations.
422
     * 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.
423
     */
424
    private async getWorkspaceConfigs(): Promise<WorkspaceConfig[]> {
425
        //get all workspace folders (we'll use these to get settings)
426
        let workspaces = await Promise.all(
139✔
427
            (await this.connection.workspace.getWorkspaceFolders() ?? []).map(async (x) => {
413!
428
                const workspaceFolder = util.uriToPath(x.uri);
143✔
429
                const brightscriptConfig = await this.getClientConfiguration<BrightScriptClientConfiguration>(x.uri, 'brightscript');
143✔
430
                return {
143✔
431
                    workspaceFolder: workspaceFolder,
432
                    excludePatterns: await this.getWorkspaceExcludeGlobs(workspaceFolder),
433
                    projects: this.normalizeProjectPaths(workspaceFolder, brightscriptConfig?.projects),
428✔
434
                    languageServer: {
435
                        enableThreading: brightscriptConfig?.languageServer?.enableThreading ?? LanguageServer.enableThreadingDefault,
1,286✔
436
                        enableProjectDiscovery: brightscriptConfig?.languageServer?.enableProjectDiscovery ?? LanguageServer.enableProjectDiscoveryDefault,
1,286✔
437
                        projectDiscoveryMaxDepth: brightscriptConfig?.languageServer?.projectDiscoveryMaxDepth ?? 15,
1,286!
438
                        projectDiscoveryExclude: brightscriptConfig?.languageServer?.projectDiscoveryExclude,
857✔
439
                        logLevel: brightscriptConfig?.languageServer?.logLevel
857✔
440

441
                    }
442
                };
443
            })
444
        );
445
        return workspaces;
137✔
446
    }
447

448
    /**
449
     * Extract project paths from settings' projects list, expanding the workspaceFolder variable if necessary
450
     */
451
    private normalizeProjectPaths(workspaceFolder: string, projects: (string | BrightScriptProjectConfiguration)[]): BrightScriptProjectConfiguration[] | undefined {
452
        return projects?.reduce((acc, project) => {
143✔
453
            if (typeof project === 'string') {
3✔
454
                acc.push({ path: project });
2✔
455
            } else if (typeof project.path === 'string') {
1!
456
                acc.push(project);
1✔
457
            }
458
            return acc;
3✔
459
        }, []).map(project => ({
3✔
460
            ...project,
461
            // eslint-disable-next-line no-template-curly-in-string
462
            path: util.standardizePath(project.path.replace('${workspaceFolder}', workspaceFolder))
463
        }));
464
    }
465

466
    private workspaceConfigsCache = new Map<string, WorkspaceConfig>();
65✔
467

468
    @AddStackToErrorMessage
469
    public async onDidChangeConfiguration(args: DidChangeConfigurationParams) {
1✔
470
        this.logger.log('onDidChangeConfiguration', 'Reloading all projects');
5✔
471

472
        const configs = new Map(
5✔
473
            (await this.getWorkspaceConfigs()).map(x => [x.workspaceFolder, x])
4✔
474
        );
475
        //find any changed configs. This includes newly created workspaces, deleted workspaces, etc.
476
        //TODO: enhance this to only reload specific projects, depending on the change
477
        if (!isEqual(configs, this.workspaceConfigsCache)) {
5✔
478
            //now that we've processed any config diffs, update the cached copy of them
479
            this.workspaceConfigsCache = configs;
3✔
480

481
            //if configuration changed, rebuild the path filterer
482
            await this.rebuildPathFilterer();
3✔
483

484
            //if the user changes any user/workspace config settings, just mass-reload all projects
485
            await this.syncProjects(true);
3✔
486
        }
487
    }
488

489

490
    @AddStackToErrorMessage
491
    public async onHover(params: TextDocumentPositionParams) {
1✔
492
        this.logger.debug('onHover', params);
×
493

UNCOV
494
        const srcPath = util.uriToPath(params.textDocument.uri);
×
UNCOV
495
        const result = await this.projectManager.getHover({ srcPath: srcPath, position: params.position });
×
UNCOV
496
        return result;
×
497
    }
498

499
    @AddStackToErrorMessage
500
    public async onWorkspaceSymbol(params: WorkspaceSymbolParams) {
1✔
501
        this.logger.debug('onWorkspaceSymbol', params);
4✔
502

503
        const result = await this.projectManager.getWorkspaceSymbol();
4✔
504
        return result;
4✔
505
    }
506

507
    @AddStackToErrorMessage
508
    public async onDocumentSymbol(params: DocumentSymbolParams) {
1✔
509
        this.logger.debug('onDocumentSymbol', params);
6✔
510

511
        const srcPath = util.uriToPath(params.textDocument.uri);
6✔
512
        const result = await this.projectManager.getDocumentSymbol({ srcPath: srcPath });
6✔
513
        return result;
6✔
514
    }
515

516
    @AddStackToErrorMessage
517
    public async onDefinition(params: TextDocumentPositionParams) {
1✔
518
        this.logger.debug('onDefinition', params);
5✔
519

520
        const srcPath = util.uriToPath(params.textDocument.uri);
5✔
521

522
        const result = this.projectManager.getDefinition({ srcPath: srcPath, position: params.position });
5✔
523
        return result;
5✔
524
    }
525

526
    @AddStackToErrorMessage
527
    public async onSignatureHelp(params: SignatureHelpParams) {
1✔
528
        this.logger.debug('onSignatureHelp', params);
4✔
529

530
        const srcPath = util.uriToPath(params.textDocument.uri);
4✔
531
        const result = await this.projectManager.getSignatureHelp({ srcPath: srcPath, position: params.position });
4✔
532
        if (result) {
4✔
533
            return result;
3✔
534
        } else {
535
            return {
1✔
536
                signatures: [],
537
                activeSignature: null,
538
                activeParameter: null
539
            };
540
        }
541

542
    }
543

544
    @AddStackToErrorMessage
545
    public async onReferences(params: ReferenceParams) {
1✔
546
        this.logger.debug('onReferences', params);
3✔
547

548
        const srcPath = util.uriToPath(params.textDocument.uri);
3✔
549
        const result = await this.projectManager.getReferences({ srcPath: srcPath, position: params.position });
3✔
550
        return result ?? [];
3!
551
    }
552

553

554
    @AddStackToErrorMessage
555
    private async onFullSemanticTokens(params: SemanticTokensParams) {
1✔
556
        this.logger.debug('onFullSemanticTokens', params);
1✔
557

558
        const srcPath = util.uriToPath(params.textDocument.uri);
1✔
559
        const result = await this.projectManager.getSemanticTokens({ srcPath: srcPath });
1✔
560

561
        return {
1✔
562
            data: encodeSemanticTokens(result)
563
        } as SemanticTokens;
564
    }
565

566
    @AddStackToErrorMessage
567
    public async onCodeAction(params: CodeActionParams) {
1✔
UNCOV
568
        this.logger.debug('onCodeAction', params);
×
569

UNCOV
570
        const srcPath = util.uriToPath(params.textDocument.uri);
×
UNCOV
571
        const result = await this.projectManager.getCodeActions({ srcPath: srcPath, range: params.range });
×
UNCOV
572
        return result;
×
573
    }
574

575

576
    @AddStackToErrorMessage
577
    public async onExecuteCommand(params: ExecuteCommandParams) {
1✔
578
        this.logger.debug('onExecuteCommand', params);
2✔
579

580
        if (params.command === CustomCommands.TranspileFile) {
2!
581
            const args = {
2✔
582
                srcPath: params.arguments[0] as string
583
            };
584
            const result = await this.projectManager.transpileFile(args);
2✔
585
            //back-compat: include `pathAbsolute` property so older vscode versions still work
586
            (result as any).pathAbsolute = result.srcPath;
2✔
587
            return result;
2✔
588
        }
589
    }
590

591
    /**
592
     * Establish a connection to the client if not already connected
593
     */
594
    private establishConnection() {
UNCOV
595
        if (!this.connection) {
×
UNCOV
596
            this.connection = createConnection(ProposedFeatures.all);
×
597
        }
UNCOV
598
        return this.connection;
×
599
    }
600

601
    /**
602
     * Send a new busy status notification to the client based on the current busy status
603
     */
604
    private sendBusyStatus() {
605
        this.busyStatusIndex = ++this.busyStatusIndex <= 0 ? 0 : this.busyStatusIndex;
448✔
606

607
        this.connection.sendNotification(NotificationName.busyStatus, {
448!
608
            status: this.projectManager.busyStatusTracker.status,
609
            timestamp: Date.now(),
610
            index: this.busyStatusIndex,
611
            activeRuns: [
612
                //extract only specific information from the active run so we know what's going on
613
                ...this.projectManager.busyStatusTracker.activeRuns.map(x => ({
595✔
614
                    scope: util.getProjectLogName(x.scope),
615
                    label: x.label,
616
                    startTime: x.startTime.getTime()
617
                }))
618
            ]
619
        })?.catch(logAndIgnoreError);
448!
620
    }
621
    private busyStatusIndex = -1;
65✔
622

623
    private pathFiltererDisposables: Array<() => void> = [];
65✔
624

625
    /**
626
     * Populate the path filterer with the client's include/exclude lists and the projects include lists
627
     * @returns the instance of the path filterer
628
     */
629
    private async rebuildPathFilterer() {
630
        //dispose of any previous pathFilterer disposables
631
        this.pathFiltererDisposables?.forEach(dispose => dispose());
19!
632
        //keep track of all the pathFilterer disposables so we can dispose them later
633
        this.pathFiltererDisposables = [];
19✔
634

635
        const workspaceConfigs = await this.getWorkspaceConfigs();
19✔
636
        await Promise.all(workspaceConfigs.map(async (workspaceConfig) => {
19✔
637
            const rootDir = util.uriToPath(workspaceConfig.workspaceFolder);
20✔
638

639
            //always exclude everything from these common folders
640
            this.pathFiltererDisposables.push(
20✔
641
                this.pathFilterer.registerExcludeList(rootDir, [
642
                    '**/node_modules/**/*',
643
                    '**/.git/**/*',
644
                    'out/**/*',
645
                    '**/.roku-deploy-staging/**/*'
646
                ])
647
            );
648
            //get any `files.exclude` patterns from the client from this workspace
649
            this.pathFiltererDisposables.push(
20✔
650
                this.pathFilterer.registerExcludeList(rootDir, workspaceConfig.excludePatterns)
651
            );
652

653
            //get any .gitignore patterns from the client from this workspace
654
            const gitignorePath = path.resolve(rootDir, '.gitignore');
20✔
655
            if (await fsExtra.pathExists(gitignorePath)) {
20✔
656
                const matcher = ignore({ ignoreCase: true }).add(
4✔
657
                    fsExtra.readFileSync(gitignorePath).toString()
658
                );
659
                this.pathFiltererDisposables.push(
4✔
660
                    this.pathFilterer.registerExcludeMatcher((p: string) => {
661
                        const relPath = path.relative(rootDir, p);
8✔
662
                        if (ignore.isPathValid(relPath)) {
8✔
663
                            return matcher.test(relPath).ignored;
5✔
664
                        } else {
665
                            //we do not have a valid relative path, so we cannot determine if it is ignored...thus it is NOT ignored
666
                            return false;
3✔
667
                        }
668
                    })
669
                );
670
            }
671
        }));
672
        this.logger.log('pathFilterer successfully reconstructed');
19✔
673

674
        return this.pathFilterer;
19✔
675
    }
676

677
    /**
678
     * Ask the client for the list of `files.exclude` and `files.watcherExclude` patterns. Useful when determining if we should process a file
679
     */
680
    private async getWorkspaceExcludeGlobs(workspaceFolder: string): Promise<string[]> {
681
        const filesConfig = await this.getClientConfiguration<{ exclude: Record<string, boolean>; watcherExclude: Record<string, boolean> }>(workspaceFolder, 'files');
149✔
682
        const searchConfig = await this.getClientConfiguration<{ exclude: Record<string, boolean> }>(workspaceFolder, 'search');
149✔
683
        const languageServerConfig = await this.getClientConfiguration<BrightScriptClientConfiguration>(workspaceFolder, 'brightscript');
149✔
684

685
        return [
149✔
686
            ...this.extractExcludes(filesConfig?.exclude),
445✔
687
            ...this.extractExcludes(filesConfig?.watcherExclude),
445✔
688
            ...this.extractExcludes(searchConfig?.exclude),
445✔
689
            ...this.extractExcludes(languageServerConfig?.languageServer?.projectDiscoveryExclude)
892✔
690
        ];
691
    }
692

693
    private extractExcludes(exclude: Record<string, boolean>): string[] {
694
        //if the exclude is not defined, return an empty array
695
        if (!exclude) {
596✔
696
            return [];
571✔
697
        }
698
        return Object
25✔
699
            .keys(exclude)
700
            .filter(x => exclude[x])
29✔
701
            //vscode files.exclude patterns support ignoring folders without needing to add `**/*`. So for our purposes, we need to
702
            //append **/* to everything without a file extension or magic at the end
703
            .map(pattern => {
704
                const result = [
29✔
705
                    //send the pattern as-is (this handles weird cases and exact file matches)
706
                    pattern
707
                ];
708
                //treat the pattern as a directory (no harm in doing this because if it's a file, the pattern will just never match anything)
709
                if (!pattern.endsWith('/**/*')) {
29✔
710
                    result.push(`${pattern}/**/*`);
24✔
711
                }
712
                return result;
29✔
713
            })
714
            .flat(1);
715
    }
716

717
    /**
718
     * Ask the project manager to sync all projects found within the list of workspaces
719
     * @param forceReload if true, all projects are discarded and recreated from scratch
720
     */
721
    private async syncProjects(forceReload = false) {
36✔
722
        const workspaces = await this.getWorkspaceConfigs();
36✔
723

724
        await this.projectManager.syncProjects(workspaces, forceReload);
36✔
725

726
        //set our logLevel to the most verbose log level found across all projects and workspaces
727
        await this.syncLogLevel();
36✔
728
    }
729

730
    /**
731
     * Given a workspaceFolder path, get the specified configuration from the client (if applicable).
732
     * Be sure to use optional chaining to traverse the result in case that configuration doesn't exist or the client doesn't support `getConfiguration`
733
     * @param workspaceFolder the folder for the workspace in the client
734
     */
735
    private async getClientConfiguration<T extends Record<string, any>>(workspaceFolder: string, section: string): Promise<T> {
736
        const scopeUri = util.pathToUri(workspaceFolder);
522✔
737
        let config = {};
522✔
738

739
        //if the client supports configuration, look for config group called "brightscript"
740
        if (this.hasConfigurationCapability) {
522✔
741
            config = await this.connection.workspace.getConfiguration({
457✔
742
                scopeUri: scopeUri,
743
                section: section
744
            });
745
        }
746
        return config as T;
522✔
747
    }
748

749
    /**
750
     * Send a critical failure notification to the client, which should show a notification of some kind
751
     */
752
    private sendCriticalFailure(message: string) {
UNCOV
753
        this.connection.sendNotification('critical-failure', message).catch(logAndIgnoreError);
×
754
    }
755

756
    /**
757
     * Send diagnostics to the client
758
     */
759
    private async sendDiagnostics(options: { project: LspProject; diagnostics: LspDiagnostic[] }) {
760
        const patch = this.diagnosticCollection.getPatch(options.project.projectNumber, options.diagnostics);
59✔
761

762
        await Promise.all(Object.keys(patch).map(async (srcPath) => {
59✔
763
            const uri = URI.file(srcPath).toString();
17✔
764
            const diagnostics = patch[srcPath].map(d => util.toDiagnostic(d, uri));
17✔
765

766
            await this.connection.sendDiagnostics({
17✔
767
                uri: uri,
768
                diagnostics: diagnostics
769
            });
770
        }));
771
    }
772
    private diagnosticCollection = new DiagnosticCollection();
65✔
773

774
    protected dispose() {
775
        this.loggerSubscription?.();
65✔
776
        this.projectManager?.dispose?.();
65!
777
    }
778
}
779

780
export enum CustomCommands {
1✔
781
    TranspileFile = 'TranspileFile'
1✔
782
}
783

784
export enum NotificationName {
1✔
785
    busyStatus = 'busyStatus'
1✔
786
}
787

788
/**
789
 * Wraps a method. If there's an error (either sync or via a promise),
790
 * this appends the error's stack trace at the end of the error message so that the connection will
791
 */
792
function AddStackToErrorMessage(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
793
    let originalMethod = descriptor.value;
16✔
794

795
    //wrapping the original method
796
    descriptor.value = function value(...args: any[]) {
16✔
797
        try {
99✔
798
            let result = originalMethod.apply(this, args);
99✔
799
            //if the result looks like a promise, log if there's a rejection
800
            if (result?.then) {
99!
801
                return Promise.resolve(result).catch((e: Error) => {
97✔
UNCOV
802
                    if (e?.stack) {
×
UNCOV
803
                        e.message = e.stack;
×
804
                    }
805
                    return Promise.reject(e);
×
806
                });
807
            } else {
808
                return result;
2✔
809
            }
810
        } catch (e: any) {
811
            if (e?.stack) {
×
UNCOV
812
                e.message = e.stack;
×
813
            }
UNCOV
814
            throw e;
×
815
        }
816
    };
817
}
818

819
type Handler<T> = {
820
    [K in keyof T as K extends `on${string}` ? K : never]:
821
    T[K] extends (arg: infer U) => void ? (arg: U) => void : never;
822
};
823
// Extracts the argument type from the function and constructs the desired interface
824
export type OnHandler<T> = {
825
    [K in keyof Handler<T>]: Handler<T>[K] extends (arg: infer U) => void ? U : never;
826
};
827

828
export interface BrightScriptProjectConfiguration {
829
    name?: string;
830
    path: string;
831
    disabled?: boolean;
832
}
833

834
export interface BrightScriptClientConfiguration {
835
    projects?: (string | BrightScriptProjectConfiguration)[];
836
    languageServer: {
837
        enableThreading: boolean;
838
        enableProjectDiscovery: boolean;
839
        projectDiscoveryExclude?: Record<string, boolean>;
840
        logLevel: LogLevel | string;
841
        projectDiscoveryMaxDepth?: number;
842
    };
843
}
844

845
function logAndIgnoreError(error: Error) {
846
    if (error?.stack) {
2!
847
        error.message = error.stack;
2✔
848
    }
849
    console.error(error);
2✔
850
}
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