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

rokucommunity / brighterscript / #13221

20 Oct 2024 10:07AM UTC coverage: 86.844% (-1.4%) from 88.214%
#13221

push

web-flow
Merge ab48ea8a7 into 7cfaaa047

11599 of 14119 branches covered (82.15%)

Branch coverage included in aggregate %.

7024 of 7616 new or added lines in 100 files covered. (92.23%)

87 existing lines in 18 files now uncovered.

12726 of 13891 relevant lines covered (91.61%)

30008.4 hits per line

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

64.78
/src/LanguageServer.ts
1
import 'array-flat-polyfill';
1✔
2
import * as fastGlob from 'fast-glob';
1✔
3
import * as path from 'path';
1✔
4
import { rokuDeploy, util as rokuDeployUtil } from 'roku-deploy';
1✔
5
import type {
6
    CompletionItem,
7
    Connection,
8
    DidChangeWatchedFilesParams,
9
    InitializeParams,
10
    ServerCapabilities,
11
    TextDocumentPositionParams,
12
    ExecuteCommandParams,
13
    WorkspaceSymbolParams,
14
    SymbolInformation,
15
    DocumentSymbolParams,
16
    ReferenceParams,
17
    SignatureHelp,
18
    SignatureHelpParams,
19
    CodeActionParams,
20
    SemanticTokensOptions,
21
    SemanticTokens,
22
    SemanticTokensParams,
23
    TextDocumentChangeEvent,
24
    Hover
25
} from 'vscode-languageserver/node';
26
import {
1✔
27
    SemanticTokensRequest,
28
    createConnection,
29
    DidChangeConfigurationNotification,
30
    FileChangeType,
31
    ProposedFeatures,
32
    TextDocuments,
33
    TextDocumentSyncKind,
34
    CodeActionKind
35
} from 'vscode-languageserver/node';
36
import { URI } from 'vscode-uri';
1✔
37
import { TextDocument } from 'vscode-languageserver-textdocument';
1✔
38
import type { BsConfig } from './BsConfig';
39
import { Deferred } from './deferred';
1✔
40
import { DiagnosticMessages } from './DiagnosticMessages';
1✔
41
import { ProgramBuilder } from './ProgramBuilder';
1✔
42
import { standardizePath as s, util } from './util';
1✔
43
import { Throttler } from './Throttler';
1✔
44
import { KeyedThrottler } from './KeyedThrottler';
1✔
45
import { DiagnosticCollection } from './DiagnosticCollection';
1✔
46
import { isBrsFile } from './astUtils/reflection';
1✔
47
import { encodeSemanticTokens, semanticTokensLegend } from './SemanticTokenUtils';
1✔
48
import type { BusyStatus } from './BusyStatusTracker';
49
import { BusyStatusTracker } from './BusyStatusTracker';
1✔
50
import { logger } from './logging';
1✔
51

52
export class LanguageServer {
1✔
53
    private connection = undefined as any as Connection;
37✔
54

55
    public projects = [] as Project[];
37✔
56

57
    /**
58
     * The number of milliseconds that should be used for language server typing debouncing
59
     */
60
    private debounceTimeout = 150;
37✔
61

62
    /**
63
     * These projects are created on the fly whenever a file is opened that is not included
64
     * in any of the workspace-based projects.
65
     * Basically these are single-file projects to at least get parsing for standalone files.
66
     * Also, they should only be created when the file is opened, and destroyed when the file is closed.
67
     */
68
    public standaloneFileProjects = {} as Record<string, Project>;
37✔
69

70
    private hasConfigurationCapability = false;
37✔
71

72
    /**
73
     * Indicates whether the client supports workspace folders
74
     */
75
    private clientHasWorkspaceFolderCapability = false;
37✔
76

77
    /**
78
     * Create a simple text document manager.
79
     * The text document manager supports full document sync only
80
     */
81
    private documents = new TextDocuments(TextDocument);
37✔
82

83
    private createConnection() {
84
        return createConnection(ProposedFeatures.all);
×
85
    }
86

87
    private loggerSubscription: (() => void) | undefined;
88

89
    private keyedThrottler = new KeyedThrottler(this.debounceTimeout);
37✔
90

91
    public validateThrottler = new Throttler(0);
37✔
92

93
    private sendDiagnosticsThrottler = new Throttler(0);
37✔
94

95
    private boundValidateAll = this.validateAll.bind(this);
37✔
96

97
    private validateAllThrottled() {
98
        return this.validateThrottler.run(this.boundValidateAll);
7✔
99
    }
100

101
    public busyStatusTracker = new BusyStatusTracker();
37✔
102

103
    //run the server
104
    public run() {
105
        // Create a connection for the server. The connection uses Node's IPC as a transport.
106
        // Also include all preview / proposed LSP features.
107
        this.connection = this.createConnection();
15✔
108

109
        // Send the current status of the busyStatusTracker anytime it changes
110
        this.busyStatusTracker.on('change', (status) => {
15✔
111
            void this.sendBusyStatus(status);
42✔
112
        });
113

114
        //disable logger colors when running in LSP mode
115
        logger.enableColor = false;
15✔
116

117
        //listen to all of the output log events and pipe them into the debug channel in the extension
118
        this.loggerSubscription = logger.subscribe((message) => {
15✔
119
            this.connection.tracer.log(message.argsText);
129✔
120
        });
121

122
        this.connection.onInitialize(this.onInitialize.bind(this));
15✔
123

124
        this.connection.onInitialized(this.onInitialized.bind(this)); //eslint-disable-line
15✔
125

126
        this.connection.onDidChangeConfiguration(this.onDidChangeConfiguration.bind(this)); //eslint-disable-line
15✔
127

128
        this.connection.onDidChangeWatchedFiles(this.onDidChangeWatchedFiles.bind(this)); //eslint-disable-line
15✔
129

130
        // The content of a text document has changed. This event is emitted
131
        // when the text document is first opened, when its content has changed,
132
        // or when document is closed without saving (original contents are sent as a change)
133
        //
134
        this.documents.onDidChangeContent(this.validateTextDocument.bind(this));
15✔
135

136
        //whenever a document gets closed
137
        this.documents.onDidClose(this.onDocumentClose.bind(this));
15✔
138

139
        // This handler provides the initial list of the completion items.
140
        this.connection.onCompletion(this.onCompletion.bind(this));
15✔
141

142
        // This handler resolves additional information for the item selected in
143
        // the completion list.
144
        this.connection.onCompletionResolve(this.onCompletionResolve.bind(this));
15✔
145

146
        this.connection.onHover(this.onHover.bind(this));
15✔
147

148
        this.connection.onExecuteCommand(this.onExecuteCommand.bind(this));
15✔
149

150
        this.connection.onDefinition(this.onDefinition.bind(this));
15✔
151

152
        this.connection.onDocumentSymbol(this.onDocumentSymbol.bind(this));
15✔
153

154
        this.connection.onWorkspaceSymbol(this.onWorkspaceSymbol.bind(this));
15✔
155

156
        this.connection.onSignatureHelp(this.onSignatureHelp.bind(this));
15✔
157

158
        this.connection.onReferences(this.onReferences.bind(this));
15✔
159

160
        this.connection.onCodeAction(this.onCodeAction.bind(this));
15✔
161

162
        //TODO switch to a more specific connection function call once they actually add it
163
        this.connection.onRequest(SemanticTokensRequest.method, this.onFullSemanticTokens.bind(this));
15✔
164

165
        /*
166
        this.connection.onDidOpenTextDocument((params) => {
167
             // A text document got opened in VSCode.
168
             // params.uri uniquely identifies the document. For documents stored on disk this is a file URI.
169
             // params.text the initial full content of the document.
170
            this.connection.console.log(`${params.textDocument.uri} opened.`);
171
        });
172
        this.connection.onDidChangeTextDocument((params) => {
173
             // The content of a text document did change in VSCode.
174
             // params.uri uniquely identifies the document.
175
             // params.contentChanges describe the content changes to the document.
176
            this.connection.console.log(`${params.textDocument.uri} changed: ${JSON.stringify(params.contentChanges)}`);
177
        });
178
        this.connection.onDidCloseTextDocument((params) => {
179
             // A text document got closed in VSCode.
180
             // params.uri uniquely identifies the document.
181
            this.connection.console.log(`${params.textDocument.uri} closed.`);
182
        });
183
        */
184

185
        // listen for open, change and close text document events
186
        this.documents.listen(this.connection);
15✔
187

188
        // Listen on the connection
189
        this.connection.listen();
15✔
190
    }
191

192
    private busyStatusIndex = -1;
37✔
193
    private async sendBusyStatus(status: BusyStatus) {
194
        this.busyStatusIndex = ++this.busyStatusIndex <= 0 ? 0 : this.busyStatusIndex;
42✔
195

196
        await this.connection.sendNotification(NotificationName.busyStatus, {
42✔
197
            status: status,
198
            timestamp: Date.now(),
199
            index: this.busyStatusIndex,
200
            activeRuns: [...this.busyStatusTracker.activeRuns]
201
        });
202
    }
203

204
    /**
205
     * Called when the client starts initialization
206
     */
207
    @AddStackToErrorMessage
208
    public onInitialize(params: InitializeParams) {
1✔
209
        let clientCapabilities = params.capabilities;
2✔
210

211
        // Does the client support the `workspace/configuration` request?
212
        // If not, we will fall back using global settings
213
        this.hasConfigurationCapability = !!(clientCapabilities.workspace && !!clientCapabilities.workspace.configuration);
2✔
214
        this.clientHasWorkspaceFolderCapability = !!(clientCapabilities.workspace && !!clientCapabilities.workspace.workspaceFolders);
2✔
215

216
        //return the capabilities of the server
217
        return {
2✔
218
            capabilities: {
219
                textDocumentSync: TextDocumentSyncKind.Full,
220
                // Tell the client that the server supports code completion
221
                completionProvider: {
222
                    resolveProvider: true,
223
                    //anytime the user types a period, auto-show the completion results
224
                    triggerCharacters: ['.'],
225
                    allCommitCharacters: ['.', '@']
226
                },
227
                documentSymbolProvider: true,
228
                workspaceSymbolProvider: true,
229
                semanticTokensProvider: {
230
                    legend: semanticTokensLegend,
231
                    full: true
232
                } as SemanticTokensOptions,
233
                referencesProvider: true,
234
                codeActionProvider: {
235
                    codeActionKinds: [CodeActionKind.Refactor]
236
                },
237
                signatureHelpProvider: {
238
                    triggerCharacters: ['(', ',']
239
                },
240
                definitionProvider: true,
241
                hoverProvider: true,
242
                executeCommandProvider: {
243
                    commands: [
244
                        CustomCommands.TranspileFile
245
                    ]
246
                }
247
            } as ServerCapabilities
248
        };
249
    }
250

251
    private initialProjectsCreated: Promise<any> | undefined;
252

253
    /**
254
     * Ask the client for the list of `files.exclude` patterns. Useful when determining if we should process a file
255
     */
256
    private async getWorkspaceExcludeGlobs(workspaceFolder: string): Promise<string[]> {
257
        let config = {
16✔
258
            exclude: {} as Record<string, boolean>
259
        };
260
        //if supported, ask vscode for the `files.exclude` configuration
261
        if (this.hasConfigurationCapability) {
16✔
262
            //get any `files.exclude` globs to use to filter
263
            config = await this.connection.workspace.getConfiguration({
14✔
264
                scopeUri: workspaceFolder,
265
                section: 'files'
266
            });
267
        }
268
        return Object
16✔
269
            .keys(config?.exclude ?? {})
96!
270
            .filter(x => config?.exclude?.[x])
1!
271
            //vscode files.exclude patterns support ignoring folders without needing to add `**/*`. So for our purposes, we need to
272
            //append **/* to everything without a file extension or magic at the end
273
            .map(pattern => [
1✔
274
                //send the pattern as-is (this handles weird cases and exact file matches)
275
                pattern,
276
                //treat the pattern as a directory (no harm in doing this because if it's a file, the pattern will just never match anything)
277
                `${pattern}/**/*`
278
            ])
279
            .flat(1)
280
            .concat([
281
                //always ignore projects from node_modules
282
                '**/node_modules/**/*'
283
            ]);
284
    }
285

286
    /**
287
     * Scan the workspace for all `bsconfig.json` files. If at least one is found, then only folders who have bsconfig.json are returned.
288
     * If none are found, then the workspaceFolder itself is treated as a project
289
     */
290
    @TrackBusyStatus
291
    private async getProjectPaths(workspaceFolder: string) {
1✔
292
        const excludes = (await this.getWorkspaceExcludeGlobs(workspaceFolder)).map(x => s`!${x}`);
17✔
293
        const files = await rokuDeploy.getFilePaths([
15✔
294
            '**/bsconfig.json',
295
            //exclude all files found in `files.exclude`
296
            ...excludes
297
        ], workspaceFolder);
298
        //if we found at least one bsconfig.json, then ALL projects must have a bsconfig.json.
299
        if (files.length > 0) {
15✔
300
            return files.map(file => s`${path.dirname(file.src)}`);
12✔
301
        }
302

303
        //look for roku project folders
304
        const rokuLikeDirs = (await Promise.all(
7✔
305
            //find all folders containing a `manifest` file
306
            (await rokuDeploy.getFilePaths([
307
                '**/manifest',
308
                ...excludes
309

310
                //is there at least one .bs|.brs file under the `/source` folder?
311
            ], workspaceFolder)).map(async manifestEntry => {
312
                const manifestDir = path.dirname(manifestEntry.src);
3✔
313
                const files = await rokuDeploy.getFilePaths([
3✔
314
                    'source/**/*.{brs,bs}',
315
                    ...excludes
316
                ], manifestDir);
317
                if (files.length > 0) {
3✔
318
                    return manifestDir;
2✔
319
                }
320
            })
321
            //throw out nulls
322
        )).filter(x => !!x);
3✔
323
        if (rokuLikeDirs.length > 0) {
7✔
324
            return rokuLikeDirs;
1✔
325
        }
326

327
        //treat the workspace folder as a brightscript project itself
328
        return [workspaceFolder];
6✔
329
    }
330

331
    /**
332
     * Find all folders with bsconfig.json files in them, and treat each as a project.
333
     * Treat workspaces that don't have a bsconfig.json as a project.
334
     * Handle situations where bsconfig.json files were added or removed (to elevate/lower workspaceFolder projects accordingly)
335
     * Leave existing projects alone if they are not affected by these changes
336
     */
337
    @TrackBusyStatus
338
    private async syncProjects() {
1✔
339
        const workspacePaths = await this.getWorkspacePaths();
14✔
340
        let projectPaths = (await Promise.all(
14✔
341
            workspacePaths.map(async workspacePath => {
342
                const projectPaths = await this.getProjectPaths(workspacePath);
15✔
343
                return projectPaths.map(projectPath => ({
20✔
344
                    projectPath: projectPath,
345
                    workspacePath: workspacePath
346
                }));
347
            })
348
        )).flat(1);
349

350
        //delete projects not represented in the list
351
        for (const project of this.getProjects()) {
14✔
352
            if (!projectPaths.find(x => x.projectPath === project.projectPath)) {
11✔
353
                this.removeProject(project);
3✔
354
            }
355
        }
356

357
        //exclude paths to projects we already have
358
        projectPaths = projectPaths.filter(x => {
14✔
359
            //only keep this project path if there's not a project with that path
360
            return !this.projects.find(project => project.projectPath === x.projectPath);
20✔
361
        });
362

363
        //dedupe by project path
364
        projectPaths = [
14✔
365
            ...projectPaths.reduce(
366
                (acc, x) => acc.set(x.projectPath, x),
16✔
367
                new Map<string, typeof projectPaths[0]>()
368
            ).values()
369
        ];
370

371
        //create missing projects
372
        await Promise.all(
14✔
373
            projectPaths.map(x => this.createProject(x.projectPath, x.workspacePath))
15✔
374
        );
375
        //flush diagnostics
376
        await this.sendDiagnostics();
14✔
377
    }
378

379
    /**
380
     * Get all workspace paths from the client
381
     */
382
    private async getWorkspacePaths() {
383
        let workspaceFolders = await this.connection.workspace.getWorkspaceFolders() ?? [];
14!
384
        return workspaceFolders.map((x) => {
14✔
385
            return util.uriToPath(x.uri);
15✔
386
        });
387
    }
388

389
    /**
390
     * Called when the client has finished initializing
391
     */
392
    @AddStackToErrorMessage
393
    @TrackBusyStatus
394
    private async onInitialized() {
1✔
395
        let projectCreatedDeferred = new Deferred();
1✔
396
        this.initialProjectsCreated = projectCreatedDeferred.promise;
1✔
397

398
        try {
1✔
399
            if (this.hasConfigurationCapability) {
1!
400
                // Register for all configuration changes.
401
                await this.connection.client.register(
×
402
                    DidChangeConfigurationNotification.type,
403
                    undefined
404
                );
405
            }
406

407
            await this.syncProjects();
1✔
408

409
            if (this.clientHasWorkspaceFolderCapability) {
1!
410
                this.connection.workspace.onDidChangeWorkspaceFolders(async (evt) => {
×
411
                    await this.syncProjects();
×
412
                });
413
            }
414
            await this.waitAllProjectFirstRuns(false);
1✔
415
            projectCreatedDeferred.resolve();
1✔
416
        } catch (e: any) {
417
            await this.sendCriticalFailure(
×
418
                `Critical failure during BrighterScript language server startup.
419
                Please file a github issue and include the contents of the 'BrighterScript Language Server' output channel.
420

421
                Error message: ${e.message}`
422
            );
423
            throw e;
×
424
        }
425
    }
426

427
    /**
428
     * Send a critical failure notification to the client, which should show a notification of some kind
429
     */
430
    private async sendCriticalFailure(message: string) {
431
        await this.connection.sendNotification('critical-failure', message);
×
432
    }
433

434
    /**
435
     * Wait for all programs' first run to complete
436
     */
437
    private async waitAllProjectFirstRuns(waitForFirstProject = true) {
32✔
438
        if (waitForFirstProject) {
33✔
439
            await this.initialProjectsCreated;
32✔
440
        }
441

442
        for (let project of this.getProjects()) {
33✔
443
            try {
35✔
444
                await project.firstRunPromise;
35✔
445
            } catch (e: any) {
446
                status = 'critical-error';
×
447
                //the first run failed...that won't change unless we reload the workspace, so replace with resolved promise
448
                //so we don't show this error again
449
                project.firstRunPromise = Promise.resolve();
×
450
                await this.sendCriticalFailure(`BrighterScript language server failed to start: \n${e.message}`);
×
451
            }
452
        }
453
    }
454

455
    /**
456
     * Event handler for when the program wants to load file contents.
457
     * anytime the program wants to load a file, check with our in-memory document cache first
458
     */
459
    private documentFileResolver(srcPath: string) {
460
        let pathUri = util.pathToUri(srcPath);
17✔
461
        let document = this.documents.get(pathUri);
17✔
462
        if (document) {
17!
463
            return document.getText();
×
464
        }
465
    }
466

467
    private async getConfigFilePath(workspacePath: string) {
468
        let scopeUri: string;
469
        if (workspacePath.startsWith('file:')) {
38!
470
            scopeUri = URI.parse(workspacePath).toString();
×
471
        } else {
472
            scopeUri = util.pathToUri(workspacePath);
38✔
473
        }
474
        let config = {
38✔
475
            configFile: undefined
476
        };
477
        //if the client supports configuration, look for config group called "brightscript"
478
        if (this.hasConfigurationCapability) {
38✔
479
            config = await this.connection.workspace.getConfiguration({
36✔
480
                scopeUri: scopeUri,
481
                section: 'brightscript'
482
            });
483
        }
484
        let configFilePath: string;
485

486
        //if there's a setting, we need to find the file or show error if it can't be found
487
        if (config?.configFile) {
38!
488
            configFilePath = path.resolve(workspacePath, config.configFile);
1✔
489
            if (await util.pathExists(configFilePath)) {
1!
490
                return configFilePath;
1✔
491
            } else {
492
                await this.sendCriticalFailure(`Cannot find config file specified in user / workspace settings at '${configFilePath}'`);
×
493
            }
494
        }
495

496
        //default to config file path found in the root of the workspace
497
        configFilePath = path.resolve(workspacePath, 'bsconfig.json');
37✔
498
        if (await util.pathExists(configFilePath)) {
37✔
499
            return configFilePath;
8✔
500
        }
501

502
        //look for the deprecated `brsconfig.json` file
503
        configFilePath = path.resolve(workspacePath, 'brsconfig.json');
29✔
504
        if (await util.pathExists(configFilePath)) {
29!
505
            return configFilePath;
×
506
        }
507

508
        //no config file could be found
509
        return undefined;
29✔
510
    }
511

512

513
    /**
514
     * A unique project counter to help distinguish log entries in lsp mode
515
     */
516
    private projectCounter = 0;
37✔
517

518
    /**
519
     * @param projectPath path to the project
520
     * @param workspacePath path to the workspace in which all project should reside or are referenced by
521
     * @param projectNumber an optional project number to assign to the project. Used when reloading projects that should keep the same number
522
     */
523
    @TrackBusyStatus
524
    private async createProject(projectPath: string, workspacePath = projectPath, projectNumber?: number) {
1✔
525
        workspacePath ??= projectPath;
36!
526
        let project = this.projects.find((x) => x.projectPath === projectPath);
36✔
527
        //skip this project if we already have it
528
        if (project) {
36!
529
            return;
×
530
        }
531

532
        let builder = new ProgramBuilder();
36✔
533
        projectNumber ??= this.projectCounter++;
36!
534
        builder.logger.prefix = `[prj${projectNumber}]`;
36✔
535
        builder.logger.log(`Created project #${projectNumber} for: "${projectPath}"`);
36✔
536

537
        //flush diagnostics every time the program finishes validating
538
        builder.plugins.add({
36✔
539
            name: 'bsc-language-server',
540
            afterProgramValidate: () => {
541
                void this.sendDiagnostics();
47✔
542
            }
543
        });
544

545
        //prevent clearing the console on run...this isn't the CLI so we want to keep a full log of everything
546
        builder.allowConsoleClearing = false;
36✔
547

548
        //look for files in our in-memory cache before going to the file system
549
        builder.addFileResolver(this.documentFileResolver.bind(this));
36✔
550

551
        let configFilePath = await this.getConfigFilePath(projectPath);
36✔
552

553
        let cwd = projectPath;
36✔
554

555
        //if the config file exists, use it and its folder as cwd
556
        if (configFilePath && await util.pathExists(configFilePath)) {
36✔
557
            cwd = path.dirname(configFilePath);
7✔
558
        } else {
559
            //config file doesn't exist...let `brighterscript` resolve the default way
560
            configFilePath = undefined;
29✔
561
        }
562

563
        const firstRunDeferred = new Deferred<any>();
36✔
564

565
        let newProject: Project = {
36✔
566
            projectNumber: projectNumber,
567
            builder: builder,
568
            firstRunPromise: firstRunDeferred.promise,
569
            projectPath: projectPath,
570
            workspacePath: workspacePath,
571
            isFirstRunComplete: false,
572
            isFirstRunSuccessful: false,
573
            configFilePath: configFilePath,
574
            isStandaloneFileProject: false
575
        };
576

577
        this.projects.push(newProject);
36✔
578

579
        try {
36✔
580
            await builder.run({
36✔
581
                cwd: cwd,
582
                project: configFilePath,
583
                watch: false,
584
                createPackage: false,
585
                deploy: false,
586
                copyToStaging: false,
587
                showDiagnosticsInConsole: false
588
            });
589
            newProject.isFirstRunComplete = true;
36✔
590
            newProject.isFirstRunSuccessful = true;
36✔
591
            firstRunDeferred.resolve();
36✔
592
        } catch (e) {
593
            builder.logger.error(e);
×
594
            firstRunDeferred.reject(e);
×
595
            newProject.isFirstRunComplete = true;
×
596
            newProject.isFirstRunSuccessful = false;
×
597
        }
598
        //if we found a deprecated brsconfig.json, add a diagnostic warning the user
599
        if (configFilePath && path.basename(configFilePath) === 'brsconfig.json') {
36!
600
            builder.addDiagnostic(configFilePath, {
×
601
                ...DiagnosticMessages.brsConfigJsonIsDeprecated(),
602
                location: util.createLocationFromRange(util.pathToUri(configFilePath), util.createRange(0, 0, 0, 0))
603
            });
604
            return this.sendDiagnostics();
×
605
        }
606
    }
607

608
    private async createStandaloneFileProject(srcPath: string) {
609
        //skip this workspace if we already have it
610
        if (this.standaloneFileProjects[srcPath]) {
3✔
611
            return this.standaloneFileProjects[srcPath];
1✔
612
        }
613

614
        let builder = new ProgramBuilder();
2✔
615

616
        //prevent clearing the console on run...this isn't the CLI so we want to keep a full log of everything
617
        builder.allowConsoleClearing = false;
2✔
618

619
        //look for files in our in-memory cache before going to the file system
620
        builder.addFileResolver(this.documentFileResolver.bind(this));
2✔
621

622
        //get the path to the directory where this file resides
623
        let cwd = path.dirname(srcPath);
2✔
624

625
        //get the closest config file and use most of the settings from that
626
        let configFilePath = await util.findClosestConfigFile(srcPath);
2✔
627
        let project: BsConfig = {};
2✔
628
        if (configFilePath) {
2!
629
            project = util.normalizeAndResolveConfig({ project: configFilePath });
×
630
        }
631
        //override the rootDir and files array
632
        project.rootDir = cwd;
2✔
633
        project.files = [{
2✔
634
            src: srcPath,
635
            dest: path.basename(srcPath)
636
        }];
637

638
        let firstRunPromise = builder.run({
2✔
639
            ...project,
640
            cwd: cwd,
641
            project: configFilePath,
642
            watch: false,
643
            createPackage: false,
644
            deploy: false,
645
            copyToStaging: false,
646
            diagnosticFilters: [
647
                //hide the "file not referenced by any other file" error..that's expected in a standalone file.
648
                1013
649
            ]
650
        }).catch((err) => {
651
            console.error(err);
×
652
        });
653

654
        let newProject: Project = {
2✔
655
            projectNumber: this.projectCounter++,
656
            builder: builder,
657
            firstRunPromise: firstRunPromise,
658
            projectPath: srcPath,
659
            workspacePath: srcPath,
660
            isFirstRunComplete: false,
661
            isFirstRunSuccessful: false,
662
            configFilePath: configFilePath,
663
            isStandaloneFileProject: true
664
        };
665

666
        this.standaloneFileProjects[srcPath] = newProject;
2✔
667

668
        await firstRunPromise.then(() => {
2✔
669
            newProject.isFirstRunComplete = true;
2✔
670
            newProject.isFirstRunSuccessful = true;
2✔
671
        }).catch(() => {
672
            newProject.isFirstRunComplete = true;
×
673
            newProject.isFirstRunSuccessful = false;
×
674
        });
675
        return newProject;
2✔
676
    }
677

678
    private getProjects() {
679
        let projects = this.projects.slice();
86✔
680
        for (let key in this.standaloneFileProjects) {
86✔
681
            projects.push(this.standaloneFileProjects[key]);
×
682
        }
683
        return projects;
86✔
684
    }
685

686
    /**
687
     * Provide a list of completion items based on the current cursor position
688
     */
689
    @AddStackToErrorMessage
690
    @TrackBusyStatus
691
    private async onCompletion(params: TextDocumentPositionParams) {
1✔
692
        //ensure programs are initialized
693
        await this.waitAllProjectFirstRuns();
1✔
694

695
        let filePath = util.uriToPath(params.textDocument.uri);
1✔
696

697
        //wait until the file has settled
698
        await this.keyedThrottler.onIdleOnce(filePath, true);
1✔
699
        // make sure validation is complete
700
        await this.validateAllThrottled();
1✔
701
        //wait for the validation cycle to settle
702
        await this.onValidateSettled();
1✔
703

704
        let completions = this
1✔
705
            .getProjects()
706
            .flatMap(workspace => workspace.builder.program.getCompletions(filePath, params.position));
3✔
707

708
        //only send one completion if name and type are the same
709
        let completionsMap = new Map<string, CompletionItem>();
1✔
710

711
        for (let completion of completions) {
1✔
712
            completion.commitCharacters = ['.'];
402✔
713
            let key = `${completion.sortText}-${completion.label}-${completion.kind}`;
402✔
714
            completionsMap.set(key, completion);
402✔
715
        }
716

717
        return [...completionsMap.values()];
1✔
718
    }
719

720
    /**
721
     * Provide a full completion item from the selection
722
     */
723
    @AddStackToErrorMessage
724
    private onCompletionResolve(item: CompletionItem): CompletionItem {
1✔
725
        if (item.data === 1) {
×
726
            item.detail = 'TypeScript details';
×
727
            item.documentation = 'TypeScript documentation';
×
728
        } else if (item.data === 2) {
×
729
            item.detail = 'JavaScript details';
×
730
            item.documentation = 'JavaScript documentation';
×
731
        }
732
        return item;
×
733
    }
734

735
    @AddStackToErrorMessage
736
    @TrackBusyStatus
737
    private async onCodeAction(params: CodeActionParams) {
1✔
738
        //ensure programs are initialized
739
        await this.waitAllProjectFirstRuns();
×
740

741
        let srcPath = util.uriToPath(params.textDocument.uri);
×
742

743
        //wait until the file has settled
744
        await this.keyedThrottler.onIdleOnce(srcPath, true);
×
745

746
        const codeActions = this
×
747
            .getProjects()
748
            //skip programs that don't have this file
749
            .filter(x => x.builder?.program?.hasFile(srcPath))
×
750
            .flatMap(workspace => workspace.builder.program.getCodeActions(srcPath, params.range));
×
751

752
        //clone the diagnostics for each code action, since certain diagnostics can have circular reference properties that kill the language server if serialized
753
        for (const codeAction of codeActions) {
×
754
            if (codeAction.diagnostics) {
×
755
                codeAction.diagnostics = codeAction.diagnostics?.map(x => util.toDiagnostic(x, params.textDocument.uri));
×
756
            }
757
        }
758
        return codeActions;
×
759
    }
760

761
    /**
762
     * Remove a project from the language server
763
     */
764
    private removeProject(project: Project) {
765
        const idx = this.projects.indexOf(project);
3✔
766
        if (idx > -1) {
3!
767
            this.projects.splice(idx, 1);
3✔
768
        }
769
        project?.builder?.dispose();
3!
770
    }
771

772
    /**
773
     * Reload each of the specified workspaces
774
     */
775
    private async reloadProjects(projects: Project[]) {
776
        await Promise.all(
×
777
            projects.map(async (project) => {
778
                //ensure the workspace has finished starting up
779
                try {
×
780
                    await project.firstRunPromise;
×
781
                } catch (e) { }
782

783
                //handle standard workspace
784
                if (project.isStandaloneFileProject === false) {
×
785
                    this.removeProject(project);
×
786

787
                    //create a new workspace/brs program
788
                    await this.createProject(project.projectPath, project.workspacePath, project.projectNumber);
×
789

790
                    //handle temp workspace
791
                } else {
792
                    project.builder.dispose();
×
793
                    delete this.standaloneFileProjects[project.projectPath];
×
794
                    await this.createStandaloneFileProject(project.projectPath);
×
795
                }
796
            })
797
        );
798
        if (projects.length > 0) {
×
799
            //wait for all of the programs to finish starting up
800
            await this.waitAllProjectFirstRuns();
×
801

802
            // valdiate all workspaces
803
            this.validateAllThrottled(); //eslint-disable-line
×
804
        }
805
    }
806

807
    private getRootDir(workspace: Project) {
808
        let options = workspace?.builder?.program?.options;
6!
809
        return options?.rootDir ?? options?.cwd;
6!
810
    }
811

812
    /**
813
     * Sometimes users will alter their bsconfig files array, and will include standalone files.
814
     * If this is the case, those standalone workspaces should be removed because the file was
815
     * included in an actual program now.
816
     *
817
     * Sometimes files that used to be included are now excluded, so those open files need to be re-processed as standalone
818
     */
819
    private async synchronizeStandaloneProjects() {
820

821
        //remove standalone workspaces that are now included in projects
822
        for (let standaloneFilePath in this.standaloneFileProjects) {
7✔
823
            let standaloneProject = this.standaloneFileProjects[standaloneFilePath];
×
824
            for (let project of this.projects) {
×
825
                await standaloneProject.firstRunPromise;
×
826

827
                let dest = rokuDeploy.getDestPath(
×
828
                    standaloneFilePath,
829
                    project?.builder?.program?.options?.files ?? [],
×
830
                    this.getRootDir(project)
831
                );
832
                //destroy this standalone workspace because the file has now been included in an actual workspace,
833
                //or if the workspace wants the file
834
                if (project?.builder?.program?.hasFile(standaloneFilePath) || dest) {
×
835
                    standaloneProject.builder.dispose();
×
836
                    delete this.standaloneFileProjects[standaloneFilePath];
×
837
                }
838
            }
839
        }
840

841
        //create standalone projects for open files that no longer have a project
842
        let textDocuments = this.documents.all();
7✔
843
        outer: for (let textDocument of textDocuments) {
7✔
844
            let filePath = URI.parse(textDocument.uri).fsPath;
3✔
845
            for (let project of this.getProjects()) {
3✔
846
                let dest = rokuDeploy.getDestPath(
6✔
847
                    filePath,
848
                    project?.builder?.program?.options?.files ?? [],
90!
849
                    this.getRootDir(project)
850
                );
851
                //if this project has the file, or it wants the file, do NOT make a standaloneProject for this file
852
                if (project?.builder?.program?.hasFile(filePath) || dest) {
6!
853
                    continue outer;
3✔
854
                }
855
            }
856
            //if we got here, no workspace has this file, so make a standalone file workspace
857
            let project = await this.createStandaloneFileProject(filePath);
×
858
            await project.firstRunPromise;
×
859
        }
860
    }
861

862
    @AddStackToErrorMessage
863
    private async onDidChangeConfiguration() {
1✔
864
        if (this.hasConfigurationCapability) {
×
865
            //if the user changes any config value, just mass-reload all projects
866
            await this.reloadProjects(this.getProjects());
×
867
            // Reset all cached document settings
868
        } else {
869
            // this.globalSettings = <ExampleSettings>(
870
            //     (change.settings.languageServerExample || this.defaultSettings)
871
            // );
872
        }
873
    }
874

875
    /**
876
     * Called when watched files changed (add/change/delete).
877
     * The CLIENT is in charge of what files to watch, so all client
878
     * implementations should ensure that all valid project
879
     * file types are watched (.brs,.bs,.xml,manifest, and any json/text/image files)
880
     */
881
    @AddStackToErrorMessage
882
    @TrackBusyStatus
883
    private async onDidChangeWatchedFiles(params: DidChangeWatchedFilesParams) {
1✔
884
        //ensure programs are initialized
885
        await this.waitAllProjectFirstRuns();
4✔
886

887
        let projects = this.getProjects();
4✔
888

889
        //convert all file paths to absolute paths
890
        let changes = params.changes.map(x => {
4✔
891
            return {
5✔
892
                type: x.type,
893
                srcPath: s`${URI.parse(x.uri).fsPath}`
894
            };
895
        });
896

897
        let keys = changes.map(x => x.srcPath);
5✔
898

899
        //filter the list of changes to only the ones that made it through the debounce unscathed
900
        changes = changes.filter(x => keys.includes(x.srcPath));
5✔
901

902
        //if we have changes to work with
903
        if (changes.length > 0) {
4!
904

905
            //if any bsconfig files were added or deleted, re-sync all projects instead of the more specific approach below
906
            if (changes.find(x => (x.type === FileChangeType.Created || x.type === FileChangeType.Deleted) && path.basename(x.srcPath).toLowerCase() === 'bsconfig.json')) {
5!
907
                return this.syncProjects();
×
908
            }
909

910
            //reload any workspace whose bsconfig.json file has changed
911
            {
912
                let projectsToReload = [] as Project[];
4✔
913
                //get the file paths as a string array
914
                let filePaths = changes.map((x) => x.srcPath);
5✔
915

916
                for (let project of projects) {
4✔
917
                    if (project.configFilePath && filePaths.includes(project.configFilePath)) {
4!
918
                        projectsToReload.push(project);
×
919
                    }
920
                }
921
                if (projectsToReload.length > 0) {
4!
922
                    //vsc can generate a ton of these changes, for vsc system files, so we need to bail if there's no work to do on any of our actual project files
923
                    //reload any projects that need to be reloaded
924
                    await this.reloadProjects(projectsToReload);
×
925
                }
926

927
                //reassign `projects` to the non-reloaded projects
928
                projects = projects.filter(x => !projectsToReload.includes(x));
4✔
929
            }
930

931
            //convert created folders into a list of files of their contents
932
            const directoryChanges = changes
4✔
933
                //get only creation items
934
                .filter(change => change.type === FileChangeType.Created)
5✔
935
                //keep only the directories
936
                .filter(change => util.isDirectorySync(change.srcPath));
3✔
937

938
            //remove the created directories from the changes array (we will add back each of their files next)
939
            changes = changes.filter(x => !directoryChanges.includes(x));
5✔
940

941
            //look up every file in each of the newly added directories
942
            const newFileChanges = directoryChanges
4✔
943
                //take just the path
944
                .map(x => x.srcPath)
2✔
945
                //exclude the roku deploy staging folder
946
                .filter(dirPath => !dirPath.includes('.roku-deploy-staging'))
2✔
947
                //get the files for each folder recursively
948
                .flatMap(dirPath => {
949
                    //look up all files
950
                    let files = fastGlob.sync('**/*', {
2✔
951
                        absolute: true,
952
                        cwd: rokuDeployUtil.toForwardSlashes(dirPath)
953
                    });
954
                    return files.map(x => {
2✔
955
                        return {
5✔
956
                            type: FileChangeType.Created,
957
                            srcPath: s`${x}`
958
                        };
959
                    });
960
                });
961

962
            //add the new file changes to the changes array.
963
            changes.push(...newFileChanges as any);
4✔
964

965
            //give every workspace the chance to handle file changes
966
            await Promise.all(
4✔
967
                projects.map((project) => this.handleFileChanges(project, changes))
4✔
968
            );
969
        }
970

971
    }
972

973
    /**
974
     * This only operates on files that match the specified files globs, so it is safe to throw
975
     * any file changes you receive with no unexpected side-effects
976
     */
977
    public async handleFileChanges(project: Project, changes: { type: FileChangeType; srcPath: string }[]) {
978
        //this loop assumes paths are both file paths and folder paths, which eliminates the need to detect.
979
        //All functions below can handle being given a file path AND a folder path, and will only operate on the one they are looking for
980
        await Promise.all(changes.map(async (change) => {
6✔
981
            await this.keyedThrottler.run(change.srcPath, async () => {
10✔
982
                if (await this.handleFileChange(project, change)) {
10✔
983
                    await this.validateAllThrottled();
5✔
984
                }
985
            });
986
        }));
987
    }
988

989
    /**
990
     * This only operates on files that match the specified files globs, so it is safe to throw
991
     * any file changes you receive with no unexpected side-effects
992
     * @returns true if the file was handled by this project, false if it was not
993
     */
994
    private async handleFileChange(project: Project, change: { type: FileChangeType; srcPath: string }): Promise<boolean> {
995
        const { program, options, rootDir } = project.builder;
10✔
996

997
        //deleted
998
        if (change.type === FileChangeType.Deleted) {
10!
999
            //try to act on this path as a directory
1000
            project.builder.removeFilesInFolder(change.srcPath);
×
1001

1002
            //if this is a file loaded in the program, remove it
1003
            if (program.hasFile(change.srcPath)) {
×
1004
                program.removeFile(change.srcPath);
×
1005
                return true;
×
1006
            } else {
1007
                return false;
×
1008
            }
1009

1010
            //created
1011
        } else if (change.type === FileChangeType.Created) {
10✔
1012
            // thanks to `onDidChangeWatchedFiles`, we can safely assume that all "Created" changes are file paths, (not directories)
1013

1014
            //get the dest path for this file.
1015
            let destPath = rokuDeploy.getDestPath(change.srcPath, options.files, rootDir);
8✔
1016

1017
            //if we got a dest path, then the program wants this file
1018
            if (destPath) {
8✔
1019
                program.setFile(
4✔
1020
                    {
1021
                        src: change.srcPath,
1022
                        dest: rokuDeploy.getDestPath(change.srcPath, options.files, rootDir)
1023
                    },
1024
                    await project.builder.getFileContents(change.srcPath)
1025
                );
1026
                return true;
4✔
1027
            } else {
1028
                //no dest path means the program doesn't want this file
1029
                return false;
4✔
1030
            }
1031

1032
            //changed
1033
        } else if (program.hasFile(change.srcPath)) {
2✔
1034
            //sometimes "changed" events are emitted on files that were actually deleted,
1035
            //so determine file existance and act accordingly
1036
            if (await util.pathExists(change.srcPath)) {
1!
1037
                program.setFile(
1✔
1038
                    {
1039
                        src: change.srcPath,
1040
                        dest: rokuDeploy.getDestPath(change.srcPath, options.files, rootDir)
1041
                    },
1042
                    await project.builder.getFileContents(change.srcPath)
1043
                );
1044
            } else {
1045
                program.removeFile(change.srcPath);
×
1046
            }
1047
            return true;
1✔
1048
        }
1049
    }
1050

1051
    @AddStackToErrorMessage
1052
    private async onHover(params: TextDocumentPositionParams) {
1✔
1053
        //ensure programs are initialized
1054
        await this.waitAllProjectFirstRuns();
×
1055

1056
        const srcPath = util.uriToPath(params.textDocument.uri);
×
1057
        let projects = this.getProjects();
×
1058
        let hovers = projects
×
1059
            //get hovers from all projects
1060
            .map((x) => x.builder.program.getHover(srcPath, params.position))
×
1061
            //flatten to a single list
1062
            .flat();
1063

1064
        const contents = [
×
1065
            ...(hovers ?? [])
×
1066
                //pull all hover contents out into a flag array of strings
1067
                .map(x => {
1068
                    return Array.isArray(x?.contents) ? x?.contents : [x?.contents];
×
1069
                }).flat()
1070
                //remove nulls
1071
                .filter(x => !!x)
×
1072
                //dedupe hovers across all projects
NEW
1073
                .reduce((set, content) => set.add(content as any), new Set<string>()).values()
×
1074
        ];
1075

1076
        if (contents.length > 0) {
×
1077
            let hover: Hover = {
×
1078
                //use the range from the first hover
1079
                range: hovers[0]?.range,
×
1080
                //the contents of all hovers
1081
                contents: contents
1082
            };
1083
            return hover;
×
1084
        }
1085
    }
1086

1087
    @AddStackToErrorMessage
1088
    private async onDocumentClose(event: TextDocumentChangeEvent<TextDocument>): Promise<void> {
1✔
1089
        const { document } = event;
×
1090
        let filePath = URI.parse(document.uri).fsPath;
×
1091
        let standaloneFileProject = this.standaloneFileProjects[filePath];
×
1092
        //if this was a temp file, close it
1093
        if (standaloneFileProject) {
×
1094
            await standaloneFileProject.firstRunPromise;
×
1095
            standaloneFileProject.builder.dispose();
×
1096
            delete this.standaloneFileProjects[filePath];
×
1097
            await this.sendDiagnostics();
×
1098
        }
1099
    }
1100

1101
    @AddStackToErrorMessage
1102
    @TrackBusyStatus
1103
    private async validateTextDocument(event: TextDocumentChangeEvent<TextDocument>): Promise<void> {
1✔
1104
        const { document } = event;
×
1105
        //ensure programs are initialized
1106
        await this.waitAllProjectFirstRuns();
×
1107

1108
        let filePath = URI.parse(document.uri).fsPath;
×
1109

1110
        try {
×
1111

1112
            //throttle file processing. first call is run immediately, and then the last call is processed.
1113
            await this.keyedThrottler.run(filePath, () => {
×
1114

1115
                let documentText = document.getText();
×
1116
                for (const project of this.getProjects()) {
×
1117
                    //only add or replace existing files. All of the files in the project should
1118
                    //have already been loaded by other means
1119
                    if (project.builder.program.hasFile(filePath)) {
×
1120
                        let rootDir = project.builder.program.options.rootDir ?? project.builder.program.options.cwd;
×
1121
                        let dest = rokuDeploy.getDestPath(filePath, project.builder.program.options.files, rootDir);
×
1122
                        project.builder.program.setFile({
×
1123
                            src: filePath,
1124
                            dest: dest
1125
                        }, documentText);
1126
                    }
1127
                }
1128
            });
1129
            // validate all projects
1130
            await this.validateAllThrottled();
×
1131
        } catch (e: any) {
1132
            await this.sendCriticalFailure(`Critical error parsing/validating ${filePath}: ${e.message}`);
×
1133
        }
1134
    }
1135

1136
    @TrackBusyStatus
1137
    private async validateAll() {
1✔
1138
        try {
7✔
1139
            //synchronize parsing for open files that were included/excluded from projects
1140
            await this.synchronizeStandaloneProjects();
7✔
1141

1142
            let projects = this.getProjects();
7✔
1143
            //validate all programs
1144
            await Promise.all(
7✔
1145
                projects.map((project) => {
1146
                    project.builder.program.validate();
8✔
1147
                    return project;
8✔
1148
                })
1149
            );
1150

1151
        } catch (e: any) {
1152
            this.connection.console.error(e);
×
1153
            await this.sendCriticalFailure(`Critical error validating project: ${e.message}${e.stack ?? ''}`);
×
1154
        }
1155
    }
1156

1157
    @AddStackToErrorMessage
1158
    @TrackBusyStatus
1159
    public async onWorkspaceSymbol(params: WorkspaceSymbolParams) {
1✔
1160
        await this.waitAllProjectFirstRuns();
4✔
1161

1162
        const results = util.flatMap(
4✔
1163
            await Promise.all(this.getProjects().map(project => {
1164
                return project.builder.program.getWorkspaceSymbols();
4✔
1165
            })),
1166
            c => c
4✔
1167
        );
1168

1169
        // Remove duplicates
1170
        const allSymbols = Object.values(results.reduce((map, symbol) => {
4✔
1171
            const key = symbol.location.uri + symbol.name;
24✔
1172
            map[key] = symbol;
24✔
1173
            return map;
24✔
1174
        }, {}));
1175
        return allSymbols as SymbolInformation[];
4✔
1176
    }
1177

1178
    @AddStackToErrorMessage
1179
    @TrackBusyStatus
1180
    public async onDocumentSymbol(params: DocumentSymbolParams) {
1✔
1181
        await this.waitAllProjectFirstRuns();
6✔
1182

1183
        await this.keyedThrottler.onIdleOnce(util.uriToPath(params.textDocument.uri), true);
6✔
1184

1185
        const srcPath = util.uriToPath(params.textDocument.uri);
6✔
1186
        for (const project of this.getProjects()) {
6✔
1187
            const file = project.builder.program.getFile(srcPath);
6✔
1188
            if (isBrsFile(file)) {
6!
1189
                return file.getDocumentSymbols();
6✔
1190
            }
1191
        }
1192
    }
1193

1194
    @AddStackToErrorMessage
1195
    @TrackBusyStatus
1196
    private async onDefinition(params: TextDocumentPositionParams) {
1✔
1197
        await this.waitAllProjectFirstRuns();
5✔
1198

1199
        const srcPath = util.uriToPath(params.textDocument.uri);
5✔
1200

1201
        const results = util.flatMap(
5✔
1202
            await Promise.all(this.getProjects().map(project => {
1203
                return project.builder.program.getDefinition(srcPath, params.position);
5✔
1204
            })),
1205
            c => c
5✔
1206
        );
1207
        return results;
5✔
1208
    }
1209

1210
    @AddStackToErrorMessage
1211
    @TrackBusyStatus
1212
    private async onSignatureHelp(params: SignatureHelpParams) {
1✔
1213
        await this.waitAllProjectFirstRuns();
4✔
1214

1215
        const filepath = util.uriToPath(params.textDocument.uri);
4✔
1216
        await this.keyedThrottler.onIdleOnce(filepath, true);
4✔
1217

1218
        try {
4✔
1219
            const signatures = util.flatMap(
4✔
1220
                await Promise.all(this.getProjects().map(project => project.builder.program.getSignatureHelp(filepath, params.position)
4✔
1221
                )),
1222
                c => c
4✔
1223
            );
1224

1225
            const activeSignature = signatures.length > 0 ? 0 : null;
4✔
1226

1227
            const activeParameter = activeSignature !== null ? signatures[activeSignature]?.index : null;
4!
1228

1229
            let results: SignatureHelp = {
4✔
1230
                signatures: signatures.map((s) => s.signature),
3✔
1231
                activeSignature: activeSignature,
1232
                activeParameter: activeParameter
1233
            };
1234
            return results;
4✔
1235
        } catch (e: any) {
1236
            this.connection.console.error(`error in onSignatureHelp: ${e.stack ?? e.message ?? e}`);
×
1237
            return {
×
1238
                signatures: [],
1239
                activeSignature: 0,
1240
                activeParameter: 0
1241
            };
1242
        }
1243
    }
1244

1245
    @AddStackToErrorMessage
1246
    @TrackBusyStatus
1247
    private async onReferences(params: ReferenceParams) {
1✔
1248
        await this.waitAllProjectFirstRuns();
3✔
1249

1250
        const position = params.position;
3✔
1251
        const srcPath = util.uriToPath(params.textDocument.uri);
3✔
1252

1253
        const results = util.flatMap(
3✔
1254
            await Promise.all(this.getProjects().map(project => {
1255
                return project.builder.program.getReferences(srcPath, position);
3✔
1256
            })),
1257
            c => c ?? []
3!
1258
        );
1259
        return results.filter((r) => r);
5✔
1260
    }
1261

1262
    private onValidateSettled() {
1263
        return Promise.all([
2✔
1264
            //wait for the validator to start running (or timeout if it never did)
1265
            this.validateThrottler.onRunOnce(100),
1266
            //wait for the validator to stop running (or resolve immediately if it's already idle)
1267
            this.validateThrottler.onIdleOnce(true)
1268
        ]);
1269
    }
1270

1271
    @AddStackToErrorMessage
1272
    @TrackBusyStatus
1273
    private async onFullSemanticTokens(params: SemanticTokensParams): Promise<SemanticTokens | undefined> {
1✔
1274
        await this.waitAllProjectFirstRuns();
1✔
1275
        //wait for the file to settle (in case there are multiple file changes in quick succession)
1276
        await this.keyedThrottler.onIdleOnce(util.uriToPath(params.textDocument.uri), true);
1✔
1277
        // make sure validation is complete
1278
        await this.validateAllThrottled();
1✔
1279
        //wait for the validation cycle to settle
1280
        await this.onValidateSettled();
1✔
1281

1282
        const srcPath = util.uriToPath(params.textDocument.uri);
1✔
1283
        for (const project of this.projects) {
1✔
1284
            //find the first program that has this file, since it would be incredibly inefficient to generate semantic tokens for the same file multiple times.
1285
            if (project.builder.program.hasFile(srcPath)) {
1!
1286
                let semanticTokens = project.builder.program.getSemanticTokens(srcPath);
1✔
1287
                if (semanticTokens !== undefined) {
1!
1288
                    return {
1✔
1289
                        data: encodeSemanticTokens(semanticTokens)
1290
                    } as SemanticTokens;
1291
                }
1292
            }
1293
        }
1294
    }
1295

1296
    private diagnosticCollection = new DiagnosticCollection();
37✔
1297

1298
    private async sendDiagnostics() {
1299
        await this.sendDiagnosticsThrottler.run(async () => {
63✔
1300
            //wait for all programs to finish running. This ensures the `Program` exists.
1301
            await Promise.all(
62✔
1302
                this.projects.map(x => x.firstRunPromise)
81✔
1303
            );
1304

1305
            //Get only the changes to diagnostics since the last time we sent them to the client
1306
            const patch = this.diagnosticCollection.getPatch(this.projects);
62✔
1307

1308
            for (let fileUri in patch) {
62✔
1309
                const diagnostics = patch[fileUri].map(d => util.toDiagnostic(d, fileUri));
6✔
1310

1311
                await this.connection.sendDiagnostics({
6✔
1312
                    uri: fileUri,
1313
                    diagnostics: diagnostics
1314
                });
1315
            }
1316
        });
1317
    }
1318

1319
    @AddStackToErrorMessage
1320
    @TrackBusyStatus
1321
    public async onExecuteCommand(params: ExecuteCommandParams) {
1✔
1322
        await this.waitAllProjectFirstRuns();
2✔
1323
        if (params.command === CustomCommands.TranspileFile) {
2!
1324
            const result = await this.transpileFile(params.arguments[0]);
2✔
1325
            //back-compat: include `pathAbsolute` property so older vscode versions still work
1326
            (result as any).pathAbsolute = result.srcPath;
2✔
1327
            return result;
2✔
1328
        }
1329
    }
1330

1331
    private async transpileFile(srcPath: string) {
1332
        //wait all program first runs
1333
        await this.waitAllProjectFirstRuns();
2✔
1334
        //find the first project that has this file
1335
        for (let project of this.getProjects()) {
2✔
1336
            if (project.builder.program.hasFile(srcPath)) {
2!
1337
                return project.builder.program.getTranspiledFileContents(srcPath);
2✔
1338
            }
1339
        }
1340
    }
1341

1342
    public dispose() {
1343
        this.loggerSubscription?.();
37✔
1344
        this.validateThrottler.dispose();
37✔
1345
    }
1346
}
1347

1348
export interface Project {
1349
    /**
1350
     * A unique number for this project, generated during this current language server session. Mostly used so we can identify which project is doing logging
1351
     */
1352
    projectNumber: number;
1353
    firstRunPromise: Promise<any>;
1354
    builder: ProgramBuilder;
1355
    /**
1356
     * The path to where the project resides
1357
     */
1358
    projectPath: string;
1359
    /**
1360
     * The path to the workspace where this project resides. A workspace can have multiple projects (by adding a bsconfig.json to each folder).
1361
     */
1362
    workspacePath: string;
1363
    isFirstRunComplete: boolean;
1364
    isFirstRunSuccessful: boolean;
1365
    configFilePath?: string;
1366
    isStandaloneFileProject: boolean;
1367
}
1368

1369
export enum CustomCommands {
1✔
1370
    TranspileFile = 'TranspileFile'
1✔
1371
}
1372

1373
export enum NotificationName {
1✔
1374
    busyStatus = 'busyStatus'
1✔
1375
}
1376

1377
/**
1378
 * Wraps a method. If there's an error (either sync or via a promise),
1379
 * this appends the error's stack trace at the end of the error message so that the connection will
1380
 */
1381
function AddStackToErrorMessage(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
1382
    let originalMethod = descriptor.value;
17✔
1383

1384
    //wrapping the original method
1385
    descriptor.value = function value(...args: any[]) {
17✔
1386
        try {
33✔
1387
            let result = originalMethod.apply(this, args);
33✔
1388
            //if the result looks like a promise, log if there's a rejection
1389
            if (result?.then) {
33!
1390
                return Promise.resolve(result).catch((e: Error) => {
31✔
1391
                    if (e?.stack) {
×
1392
                        e.message = e.stack;
×
1393
                    }
1394
                    return Promise.reject(e);
×
1395
                });
1396
            } else {
1397
                return result;
2✔
1398
            }
1399
        } catch (e: any) {
1400
            if (e?.stack) {
×
1401
                e.message = e.stack;
×
1402
            }
1403
            throw e;
×
1404
        }
1405
    };
1406
}
1407

1408
/**
1409
 * An annotation used to wrap the method in a busyStatus tracking call
1410
 */
1411
function TrackBusyStatus(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
1412
    let originalMethod = descriptor.value;
16✔
1413

1414
    //wrapping the original method
1415
    descriptor.value = function value(this: LanguageServer, ...args: any[]) {
16✔
1416
        return this.busyStatusTracker.run(() => {
103✔
1417
            return originalMethod.apply(this, args);
103✔
1418
        }, originalMethod.name);
1419
    };
1420
}
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