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

rokucommunity / brighterscript / #10591

pending completion
#10591

push

TwitchBronBron
0.65.4

5563 of 6775 branches covered (82.11%)

Branch coverage included in aggregate %.

8487 of 9161 relevant lines covered (92.64%)

1623.48 hits per line

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

57.45
/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 { Logger } from './Logger';
1✔
44
import { Throttler } from './Throttler';
1✔
45
import { KeyedThrottler } from './KeyedThrottler';
1✔
46
import { DiagnosticCollection } from './DiagnosticCollection';
1✔
47
import { isBrsFile } from './astUtils/reflection';
1✔
48
import { encodeSemanticTokens, semanticTokensLegend } from './SemanticTokenUtils';
1✔
49
import type { BusyStatus } from './BusyStatusTracker';
50
import { BusyStatusTracker } from './BusyStatusTracker';
1✔
51

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

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

57
    /**
58
     * The number of milliseconds that should be used for language server typing debouncing
59
     */
60
    private debounceTimeout = 150;
35✔
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>;
35✔
69

70
    private hasConfigurationCapability = false;
35✔
71

72
    /**
73
     * Indicates whether the client supports workspace folders
74
     */
75
    private clientHasWorkspaceFolderCapability = false;
35✔
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);
35✔
82

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

87
    private loggerSubscription: () => void;
88

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

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

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

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

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

101
    public busyStatusTracker = new BusyStatusTracker();
35✔
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();
14✔
108

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

114
        //listen to all of the output log events and pipe them into the debug channel in the extension
115
        this.loggerSubscription = Logger.subscribe((text) => {
14✔
116
            this.connection.tracer.log(text);
111✔
117
        });
118

119
        this.connection.onInitialize(this.onInitialize.bind(this));
14✔
120

121
        this.connection.onInitialized(this.onInitialized.bind(this)); //eslint-disable-line
14✔
122

123
        this.connection.onDidChangeConfiguration(this.onDidChangeConfiguration.bind(this)); //eslint-disable-line
14✔
124

125
        this.connection.onDidChangeWatchedFiles(this.onDidChangeWatchedFiles.bind(this)); //eslint-disable-line
14✔
126

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

133
        //whenever a document gets closed
134
        this.documents.onDidClose(this.onDocumentClose.bind(this));
14✔
135

136
        // This handler provides the initial list of the completion items.
137
        this.connection.onCompletion(this.onCompletion.bind(this));
14✔
138

139
        // This handler resolves additional information for the item selected in
140
        // the completion list.
141
        this.connection.onCompletionResolve(this.onCompletionResolve.bind(this));
14✔
142

143
        this.connection.onHover(this.onHover.bind(this));
14✔
144

145
        this.connection.onExecuteCommand(this.onExecuteCommand.bind(this));
14✔
146

147
        this.connection.onDefinition(this.onDefinition.bind(this));
14✔
148

149
        this.connection.onDocumentSymbol(this.onDocumentSymbol.bind(this));
14✔
150

151
        this.connection.onWorkspaceSymbol(this.onWorkspaceSymbol.bind(this));
14✔
152

153
        this.connection.onSignatureHelp(this.onSignatureHelp.bind(this));
14✔
154

155
        this.connection.onReferences(this.onReferences.bind(this));
14✔
156

157
        this.connection.onCodeAction(this.onCodeAction.bind(this));
14✔
158

159
        //TODO switch to a more specific connection function call once they actually add it
160
        this.connection.onRequest(SemanticTokensRequest.method, this.onFullSemanticTokens.bind(this));
14✔
161

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

182
        // listen for open, change and close text document events
183
        this.documents.listen(this.connection);
14✔
184

185
        // Listen on the connection
186
        this.connection.listen();
14✔
187
    }
188

189
    private busyStatusIndex = -1;
35✔
190
    private sendBusyStatus(status: BusyStatus) {
191
        this.busyStatusIndex = ++this.busyStatusIndex <= 0 ? 0 : this.busyStatusIndex;
38✔
192

193
        this.connection.sendNotification(NotificationName.busyStatus, {
38✔
194
            status: status,
195
            timestamp: Date.now(),
196
            index: this.busyStatusIndex,
197
            activeRuns: [...this.busyStatusTracker.activeRuns]
198
        });
199
    }
200

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

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

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

248
    private initialProjectsCreated: Promise<any>;
249

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

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

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

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

324
        //treat the workspace folder as a brightscript project itself
325
        return [workspaceFolder];
6✔
326
    }
327

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

347
        //delete projects not represented in the list
348
        for (const project of this.getProjects()) {
13✔
349
            if (!projectPaths.find(x => x.projectPath === project.projectPath)) {
5✔
350
                this.removeProject(project);
3✔
351
            }
352
        }
353

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

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

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

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

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

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

404
            await this.syncProjects();
1✔
405

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

418
                Error message: ${e.message}`
419
            );
420
            throw e;
×
421
        }
422
    }
423

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

431
    /**
432
     * Wait for all programs' first run to complete
433
     */
434
    private async waitAllProjectFirstRuns(waitForFirstProject = true) {
30✔
435
        if (waitForFirstProject) {
31✔
436
            await this.initialProjectsCreated;
30✔
437
        }
438

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

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

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

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

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

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

505
        //no config file could be found
506
        return undefined;
25✔
507
    }
508

509

510
    /**
511
     * A unique project counter to help distinguish log entries in lsp mode
512
     */
513
    private projectCounter = 0;
35✔
514

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

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

534
        //flush diagnostics every time the program finishes validating
535
        builder.plugins.add({
32✔
536
            name: 'bsc-language-server',
537
            afterProgramValidate: () => {
538
                void this.sendDiagnostics();
38✔
539
            }
540
        });
541

542
        //prevent clearing the console on run...this isn't the CLI so we want to keep a full log of everything
543
        builder.allowConsoleClearing = false;
32✔
544

545
        //look for files in our in-memory cache before going to the file system
546
        builder.addFileResolver(this.documentFileResolver.bind(this));
32✔
547

548
        let configFilePath = await this.getConfigFilePath(projectPath);
32✔
549

550
        let cwd = projectPath;
32✔
551

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

560
        const firstRunDeferred = new Deferred<any>();
32✔
561

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

574
        this.projects.push(newProject);
32✔
575

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

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

611
        let builder = new ProgramBuilder();
2✔
612

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

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

619
        //get the path to the directory where this file resides
620
        let cwd = path.dirname(srcPath);
2✔
621

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

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

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

663
        this.standaloneFileProjects[srcPath] = newProject;
2✔
664

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

675
    private getProjects() {
676
        let projects = this.projects.slice();
75✔
677
        for (let key in this.standaloneFileProjects) {
75✔
678
            projects.push(this.standaloneFileProjects[key]);
×
679
        }
680
        return projects;
75✔
681
    }
682

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

692
        let filePath = util.uriToPath(params.textDocument.uri);
×
693

694
        //wait until the file has settled
695
        await this.keyedThrottler.onIdleOnce(filePath, true);
×
696

697
        let completions = this
×
698
            .getProjects()
699
            .flatMap(workspace => workspace.builder.program.getCompletions(filePath, params.position));
×
700

701
        for (let completion of completions) {
×
702
            completion.commitCharacters = ['.'];
×
703
        }
704

705
        return completions;
×
706
    }
707

708
    /**
709
     * Provide a full completion item from the selection
710
     */
711
    @AddStackToErrorMessage
712
    private onCompletionResolve(item: CompletionItem): CompletionItem {
1✔
713
        if (item.data === 1) {
×
714
            item.detail = 'TypeScript details';
×
715
            item.documentation = 'TypeScript documentation';
×
716
        } else if (item.data === 2) {
×
717
            item.detail = 'JavaScript details';
×
718
            item.documentation = 'JavaScript documentation';
×
719
        }
720
        return item;
×
721
    }
722

723
    @AddStackToErrorMessage
724
    @TrackBusyStatus
725
    private async onCodeAction(params: CodeActionParams) {
1✔
726
        //ensure programs are initialized
727
        await this.waitAllProjectFirstRuns();
×
728

729
        let srcPath = util.uriToPath(params.textDocument.uri);
×
730

731
        //wait until the file has settled
732
        await this.keyedThrottler.onIdleOnce(srcPath, true);
×
733

734
        const codeActions = this
×
735
            .getProjects()
736
            //skip programs that don't have this file
737
            .filter(x => x.builder?.program?.hasFile(srcPath))
×
738
            .flatMap(workspace => workspace.builder.program.getCodeActions(srcPath, params.range));
×
739

740
        //clone the diagnostics for each code action, since certain diagnostics can have circular reference properties that kill the language server if serialized
741
        for (const codeAction of codeActions) {
×
742
            if (codeAction.diagnostics) {
×
743
                codeAction.diagnostics = codeAction.diagnostics.map(x => util.toDiagnostic(x, params.textDocument.uri));
×
744
            }
745
        }
746
        return codeActions;
×
747
    }
748

749
    /**
750
     * Remove a project from the language server
751
     */
752
    private removeProject(project: Project) {
753
        const idx = this.projects.indexOf(project);
3✔
754
        if (idx > -1) {
3!
755
            this.projects.splice(idx, 1);
3✔
756
        }
757
        project?.builder?.dispose();
3!
758
    }
759

760
    /**
761
     * Reload each of the specified workspaces
762
     */
763
    private async reloadProjects(projects: Project[]) {
764
        await Promise.all(
×
765
            projects.map(async (project) => {
766
                //ensure the workspace has finished starting up
767
                try {
×
768
                    await project.firstRunPromise;
×
769
                } catch (e) { }
770

771
                //handle standard workspace
772
                if (project.isStandaloneFileProject === false) {
×
773
                    this.removeProject(project);
×
774

775
                    //create a new workspace/brs program
776
                    await this.createProject(project.projectPath, project.workspacePath, project.projectNumber);
×
777

778
                    //handle temp workspace
779
                } else {
780
                    project.builder.dispose();
×
781
                    delete this.standaloneFileProjects[project.projectPath];
×
782
                    await this.createStandaloneFileProject(project.projectPath);
×
783
                }
784
            })
785
        );
786
        if (projects.length > 0) {
×
787
            //wait for all of the programs to finish starting up
788
            await this.waitAllProjectFirstRuns();
×
789

790
            // valdiate all workspaces
791
            this.validateAllThrottled(); //eslint-disable-line
×
792
        }
793
    }
794

795
    private getRootDir(workspace: Project) {
796
        let options = workspace?.builder?.program?.options;
×
797
        return options?.rootDir ?? options?.cwd;
×
798
    }
799

800
    /**
801
     * Sometimes users will alter their bsconfig files array, and will include standalone files.
802
     * If this is the case, those standalone workspaces should be removed because the file was
803
     * included in an actual program now.
804
     *
805
     * Sometimes files that used to be included are now excluded, so those open files need to be re-processed as standalone
806
     */
807
    private async synchronizeStandaloneProjects() {
808

809
        //remove standalone workspaces that are now included in projects
810
        for (let standaloneFilePath in this.standaloneFileProjects) {
4✔
811
            let standaloneProject = this.standaloneFileProjects[standaloneFilePath];
×
812
            for (let project of this.projects) {
×
813
                await standaloneProject.firstRunPromise;
×
814

815
                let dest = rokuDeploy.getDestPath(
×
816
                    standaloneFilePath,
817
                    project?.builder?.program?.options?.files ?? [],
×
818
                    this.getRootDir(project)
819
                );
820
                //destroy this standalone workspace because the file has now been included in an actual workspace,
821
                //or if the workspace wants the file
822
                if (project?.builder?.program?.hasFile(standaloneFilePath) || dest) {
×
823
                    standaloneProject.builder.dispose();
×
824
                    delete this.standaloneFileProjects[standaloneFilePath];
×
825
                }
826
            }
827
        }
828

829
        //create standalone projects for open files that no longer have a project
830
        let textDocuments = this.documents.all();
4✔
831
        outer: for (let textDocument of textDocuments) {
4✔
832
            let filePath = URI.parse(textDocument.uri).fsPath;
×
833
            for (let project of this.getProjects()) {
×
834
                let dest = rokuDeploy.getDestPath(
×
835
                    filePath,
836
                    project?.builder?.program?.options?.files ?? [],
×
837
                    this.getRootDir(project)
838
                );
839
                //if this project has the file, or it wants the file, do NOT make a standaloneProject for this file
840
                if (project?.builder?.program?.hasFile(filePath) || dest) {
×
841
                    continue outer;
×
842
                }
843
            }
844
            //if we got here, no workspace has this file, so make a standalone file workspace
845
            let project = await this.createStandaloneFileProject(filePath);
×
846
            await project.firstRunPromise;
×
847
        }
848
    }
849

850
    @AddStackToErrorMessage
851
    private async onDidChangeConfiguration() {
1✔
852
        if (this.hasConfigurationCapability) {
×
853
            //if the user changes any config value, just mass-reload all projects
854
            await this.reloadProjects(this.getProjects());
×
855
            // Reset all cached document settings
856
        } else {
857
            // this.globalSettings = <ExampleSettings>(
858
            //     (change.settings.languageServerExample || this.defaultSettings)
859
            // );
860
        }
861
    }
862

863
    /**
864
     * Called when watched files changed (add/change/delete).
865
     * The CLIENT is in charge of what files to watch, so all client
866
     * implementations should ensure that all valid project
867
     * file types are watched (.brs,.bs,.xml,manifest, and any json/text/image files)
868
     */
869
    @AddStackToErrorMessage
870
    @TrackBusyStatus
871
    private async onDidChangeWatchedFiles(params: DidChangeWatchedFilesParams) {
1✔
872
        //ensure programs are initialized
873
        await this.waitAllProjectFirstRuns();
4✔
874

875
        let projects = this.getProjects();
4✔
876

877
        //convert all file paths to absolute paths
878
        let changes = params.changes.map(x => {
4✔
879
            return {
5✔
880
                type: x.type,
881
                srcPath: s`${URI.parse(x.uri).fsPath}`
882
            };
883
        });
884

885
        let keys = changes.map(x => x.srcPath);
5✔
886

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

890
        //if we have changes to work with
891
        if (changes.length > 0) {
4!
892

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

898
            //reload any workspace whose bsconfig.json file has changed
899
            {
900
                let projectsToReload = [] as Project[];
4✔
901
                //get the file paths as a string array
902
                let filePaths = changes.map((x) => x.srcPath);
5✔
903

904
                for (let project of projects) {
4✔
905
                    if (project.configFilePath && filePaths.includes(project.configFilePath)) {
4!
906
                        projectsToReload.push(project);
×
907
                    }
908
                }
909
                if (projectsToReload.length > 0) {
4!
910
                    //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
911
                    //reload any projects that need to be reloaded
912
                    await this.reloadProjects(projectsToReload);
×
913
                }
914

915
                //reassign `projects` to the non-reloaded projects
916
                projects = projects.filter(x => !projectsToReload.includes(x));
4✔
917
            }
918

919
            //convert created folders into a list of files of their contents
920
            const directoryChanges = changes
4✔
921
                //get only creation items
922
                .filter(change => change.type === FileChangeType.Created)
5✔
923
                //keep only the directories
924
                .filter(change => util.isDirectorySync(change.srcPath));
3✔
925

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

929
            //look up every file in each of the newly added directories
930
            const newFileChanges = directoryChanges
4✔
931
                //take just the path
932
                .map(x => x.srcPath)
2✔
933
                //exclude the roku deploy staging folder
934
                .filter(dirPath => !dirPath.includes('.roku-deploy-staging'))
2✔
935
                //get the files for each folder recursively
936
                .flatMap(dirPath => {
937
                    //look up all files
938
                    let files = fastGlob.sync('**/*', {
2✔
939
                        absolute: true,
940
                        cwd: rokuDeployUtil.toForwardSlashes(dirPath)
941
                    });
942
                    return files.map(x => {
2✔
943
                        return {
5✔
944
                            type: FileChangeType.Created,
945
                            srcPath: s`${x}`
946
                        };
947
                    });
948
                });
949

950
            //add the new file changes to the changes array.
951
            changes.push(...newFileChanges as any);
4✔
952

953
            //give every workspace the chance to handle file changes
954
            await Promise.all(
4✔
955
                projects.map((project) => this.handleFileChanges(project, changes))
4✔
956
            );
957
        }
958

959
    }
960

961
    /**
962
     * This only operates on files that match the specified files globs, so it is safe to throw
963
     * any file changes you receive with no unexpected side-effects
964
     */
965
    public async handleFileChanges(project: Project, changes: { type: FileChangeType; srcPath: string }[]) {
966
        //this loop assumes paths are both file paths and folder paths, which eliminates the need to detect.
967
        //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
968
        let consumeCount = 0;
6✔
969
        await Promise.all(changes.map(async (change) => {
6✔
970
            await this.keyedThrottler.run(change.srcPath, async () => {
10✔
971
                consumeCount += await this.handleFileChange(project, change) ? 1 : 0;
10✔
972
            });
973
        }));
974

975
        if (consumeCount > 0) {
6✔
976
            await this.validateAllThrottled();
4✔
977
        }
978
    }
979

980
    /**
981
     * This only operates on files that match the specified files globs, so it is safe to throw
982
     * any file changes you receive with no unexpected side-effects
983
     */
984
    private async handleFileChange(project: Project, change: { type: FileChangeType; srcPath: string }) {
985
        const { program, options, rootDir } = project.builder;
10✔
986

987
        //deleted
988
        if (change.type === FileChangeType.Deleted) {
10!
989
            //try to act on this path as a directory
990
            project.builder.removeFilesInFolder(change.srcPath);
×
991

992
            //if this is a file loaded in the program, remove it
993
            if (program.hasFile(change.srcPath)) {
×
994
                program.removeFile(change.srcPath);
×
995
                return true;
×
996
            } else {
997
                return false;
×
998
            }
999

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

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

1007
            //if we got a dest path, then the program wants this file
1008
            if (destPath) {
8✔
1009
                program.setFile(
4✔
1010
                    {
1011
                        src: change.srcPath,
1012
                        dest: rokuDeploy.getDestPath(change.srcPath, options.files, rootDir)
1013
                    },
1014
                    await project.builder.getFileContents(change.srcPath)
1015
                );
1016
                return true;
4✔
1017
            } else {
1018
                //no dest path means the program doesn't want this file
1019
                return false;
4✔
1020
            }
1021

1022
            //changed
1023
        } else if (program.hasFile(change.srcPath)) {
2✔
1024
            //sometimes "changed" events are emitted on files that were actually deleted,
1025
            //so determine file existance and act accordingly
1026
            if (await util.pathExists(change.srcPath)) {
1!
1027
                program.setFile(
1✔
1028
                    {
1029
                        src: change.srcPath,
1030
                        dest: rokuDeploy.getDestPath(change.srcPath, options.files, rootDir)
1031
                    },
1032
                    await project.builder.getFileContents(change.srcPath)
1033
                );
1034
            } else {
1035
                program.removeFile(change.srcPath);
×
1036
            }
1037
            return true;
1✔
1038
        }
1039
    }
1040

1041
    @AddStackToErrorMessage
1042
    private async onHover(params: TextDocumentPositionParams) {
1✔
1043
        //ensure programs are initialized
1044
        await this.waitAllProjectFirstRuns();
×
1045

1046
        const srcPath = util.uriToPath(params.textDocument.uri);
×
1047
        let projects = this.getProjects();
×
1048
        let hovers = projects
×
1049
            //get hovers from all projects
1050
            .map((x) => x.builder.program.getHover(srcPath, params.position))
×
1051
            //flatten to a single list
1052
            .flat();
1053

1054
        const contents = [
×
1055
            ...(hovers ?? [])
×
1056
                //pull all hover contents out into a flag array of strings
1057
                .map(x => {
1058
                    return Array.isArray(x?.contents) ? x?.contents : [x?.contents];
×
1059
                }).flat()
1060
                //remove nulls
1061
                .filter(x => !!x)
×
1062
                //dedupe hovers across all projects
1063
                .reduce((set, content) => set.add(content), new Set<string>()).values()
×
1064
        ];
1065

1066
        if (contents.length > 0) {
×
1067
            let hover: Hover = {
×
1068
                //use the range from the first hover
1069
                range: hovers[0]?.range,
×
1070
                //the contents of all hovers
1071
                contents: contents
1072
            };
1073
            return hover;
×
1074
        }
1075
    }
1076

1077
    @AddStackToErrorMessage
1078
    private async onDocumentClose(event: TextDocumentChangeEvent<TextDocument>): Promise<void> {
1✔
1079
        const { document } = event;
×
1080
        let filePath = URI.parse(document.uri).fsPath;
×
1081
        let standaloneFileProject = this.standaloneFileProjects[filePath];
×
1082
        //if this was a temp file, close it
1083
        if (standaloneFileProject) {
×
1084
            await standaloneFileProject.firstRunPromise;
×
1085
            standaloneFileProject.builder.dispose();
×
1086
            delete this.standaloneFileProjects[filePath];
×
1087
            await this.sendDiagnostics();
×
1088
        }
1089
    }
1090

1091
    @AddStackToErrorMessage
1092
    @TrackBusyStatus
1093
    private async validateTextDocument(event: TextDocumentChangeEvent<TextDocument>): Promise<void> {
1✔
1094
        const { document } = event;
×
1095
        //ensure programs are initialized
1096
        await this.waitAllProjectFirstRuns();
×
1097

1098
        let filePath = URI.parse(document.uri).fsPath;
×
1099

1100
        try {
×
1101

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

1105
                let documentText = document.getText();
×
1106
                for (const project of this.getProjects()) {
×
1107
                    //only add or replace existing files. All of the files in the project should
1108
                    //have already been loaded by other means
1109
                    if (project.builder.program.hasFile(filePath)) {
×
1110
                        let rootDir = project.builder.program.options.rootDir ?? project.builder.program.options.cwd;
×
1111
                        let dest = rokuDeploy.getDestPath(filePath, project.builder.program.options.files, rootDir);
×
1112
                        project.builder.program.setFile({
×
1113
                            src: filePath,
1114
                            dest: dest
1115
                        }, documentText);
1116
                    }
1117
                }
1118
            });
1119
            // validate all projects
1120
            await this.validateAllThrottled();
×
1121
        } catch (e: any) {
1122
            this.sendCriticalFailure(`Critical error parsing/validating ${filePath}: ${e.message}`);
×
1123
        }
1124
    }
1125

1126
    @TrackBusyStatus
1127
    private async validateAll() {
1✔
1128
        try {
4✔
1129
            //synchronize parsing for open files that were included/excluded from projects
1130
            await this.synchronizeStandaloneProjects();
4✔
1131

1132
            let projects = this.getProjects();
4✔
1133

1134
            //validate all programs
1135
            await Promise.all(
4✔
1136
                projects.map((project) => {
1137
                    project.builder.program.validate();
3✔
1138
                    return project;
3✔
1139
                })
1140
            );
1141
        } catch (e: any) {
1142
            this.connection.console.error(e);
×
1143
            this.sendCriticalFailure(`Critical error validating project: ${e.message}${e.stack ?? ''}`);
×
1144
        }
1145
    }
1146

1147
    @AddStackToErrorMessage
1148
    @TrackBusyStatus
1149
    public async onWorkspaceSymbol(params: WorkspaceSymbolParams) {
1✔
1150
        await this.waitAllProjectFirstRuns();
4✔
1151

1152
        const results = util.flatMap(
4✔
1153
            await Promise.all(this.getProjects().map(project => {
1154
                return project.builder.program.getWorkspaceSymbols();
4✔
1155
            })),
1156
            c => c
4✔
1157
        );
1158

1159
        // Remove duplicates
1160
        const allSymbols = Object.values(results.reduce((map, symbol) => {
4✔
1161
            const key = symbol.location.uri + symbol.name;
24✔
1162
            map[key] = symbol;
24✔
1163
            return map;
24✔
1164
        }, {}));
1165
        return allSymbols as SymbolInformation[];
4✔
1166
    }
1167

1168
    @AddStackToErrorMessage
1169
    @TrackBusyStatus
1170
    public async onDocumentSymbol(params: DocumentSymbolParams) {
1✔
1171
        await this.waitAllProjectFirstRuns();
6✔
1172

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

1175
        const srcPath = util.uriToPath(params.textDocument.uri);
6✔
1176
        for (const project of this.getProjects()) {
6✔
1177
            const file = project.builder.program.getFile(srcPath);
6✔
1178
            if (isBrsFile(file)) {
6!
1179
                return file.getDocumentSymbols();
6✔
1180
            }
1181
        }
1182
    }
1183

1184
    @AddStackToErrorMessage
1185
    @TrackBusyStatus
1186
    private async onDefinition(params: TextDocumentPositionParams) {
1✔
1187
        await this.waitAllProjectFirstRuns();
5✔
1188

1189
        const srcPath = util.uriToPath(params.textDocument.uri);
5✔
1190

1191
        const results = util.flatMap(
5✔
1192
            await Promise.all(this.getProjects().map(project => {
1193
                return project.builder.program.getDefinition(srcPath, params.position);
5✔
1194
            })),
1195
            c => c
5✔
1196
        );
1197
        return results;
5✔
1198
    }
1199

1200
    @AddStackToErrorMessage
1201
    @TrackBusyStatus
1202
    private async onSignatureHelp(params: SignatureHelpParams) {
1✔
1203
        await this.waitAllProjectFirstRuns();
3✔
1204

1205
        const filepath = util.uriToPath(params.textDocument.uri);
3✔
1206
        await this.keyedThrottler.onIdleOnce(filepath, true);
3✔
1207

1208
        try {
3✔
1209
            const signatures = util.flatMap(
3✔
1210
                await Promise.all(this.getProjects().map(project => project.builder.program.getSignatureHelp(filepath, params.position)
3✔
1211
                )),
1212
                c => c
3✔
1213
            );
1214

1215
            const activeSignature = signatures.length > 0 ? 0 : null;
3!
1216

1217
            const activeParameter = activeSignature >= 0 ? signatures[activeSignature]?.index : null;
3!
1218

1219
            let results: SignatureHelp = {
3✔
1220
                signatures: signatures.map((s) => s.signature),
3✔
1221
                activeSignature: activeSignature,
1222
                activeParameter: activeParameter
1223
            };
1224
            return results;
3✔
1225
        } catch (e: any) {
1226
            this.connection.console.error(`error in onSignatureHelp: ${e.stack ?? e.message ?? e}`);
×
1227
            return {
×
1228
                signatures: [],
1229
                activeSignature: 0,
1230
                activeParameter: 0
1231
            };
1232
        }
1233
    }
1234

1235
    @AddStackToErrorMessage
1236
    @TrackBusyStatus
1237
    private async onReferences(params: ReferenceParams) {
1✔
1238
        await this.waitAllProjectFirstRuns();
3✔
1239

1240
        const position = params.position;
3✔
1241
        const srcPath = util.uriToPath(params.textDocument.uri);
3✔
1242

1243
        const results = util.flatMap(
3✔
1244
            await Promise.all(this.getProjects().map(project => {
1245
                return project.builder.program.getReferences(srcPath, position);
3✔
1246
            })),
1247
            c => c
3✔
1248
        );
1249
        return results.filter((r) => r);
5✔
1250
    }
1251

1252
    private onValidateSettled() {
1253
        return Promise.all([
1✔
1254
            //wait for the validator to start running (or timeout if it never did)
1255
            this.validateThrottler.onRunOnce(100),
1256
            //wait for the validator to stop running (or resolve immediately if it's already idle)
1257
            this.validateThrottler.onIdleOnce(true)
1258
        ]);
1259
    }
1260

1261
    @AddStackToErrorMessage
1262
    @TrackBusyStatus
1263
    private async onFullSemanticTokens(params: SemanticTokensParams) {
1✔
1264
        await this.waitAllProjectFirstRuns();
1✔
1265
        //wait for the file to settle (in case there are multiple file changes in quick succession)
1266
        await this.keyedThrottler.onIdleOnce(util.uriToPath(params.textDocument.uri), true);
1✔
1267
        //wait for the validation cycle to settle
1268
        await this.onValidateSettled();
1✔
1269

1270
        const srcPath = util.uriToPath(params.textDocument.uri);
1✔
1271
        for (const project of this.projects) {
1✔
1272
            //find the first program that has this file, since it would be incredibly inefficient to generate semantic tokens for the same file multiple times.
1273
            if (project.builder.program.hasFile(srcPath)) {
1!
1274
                let semanticTokens = project.builder.program.getSemanticTokens(srcPath);
1✔
1275
                return {
1✔
1276
                    data: encodeSemanticTokens(semanticTokens)
1277
                } as SemanticTokens;
1278
            }
1279
        }
1280
    }
1281

1282
    private diagnosticCollection = new DiagnosticCollection();
35✔
1283

1284
    private async sendDiagnostics() {
1285
        await this.sendDiagnosticsThrottler.run(async () => {
53✔
1286
            //wait for all programs to finish running. This ensures the `Program` exists.
1287
            await Promise.all(
53✔
1288
                this.projects.map(x => x.firstRunPromise)
63✔
1289
            );
1290

1291
            //Get only the changes to diagnostics since the last time we sent them to the client
1292
            const patch = this.diagnosticCollection.getPatch(this.projects);
53✔
1293

1294
            for (let filePath in patch) {
53✔
1295
                const uri = URI.file(filePath).toString();
5✔
1296
                const diagnostics = patch[filePath].map(d => util.toDiagnostic(d, uri));
5✔
1297

1298
                this.connection.sendDiagnostics({
5✔
1299
                    uri: uri,
1300
                    diagnostics: diagnostics
1301
                });
1302
            }
1303
        });
1304
    }
1305

1306
    @AddStackToErrorMessage
1307
    @TrackBusyStatus
1308
    public async onExecuteCommand(params: ExecuteCommandParams) {
1✔
1309
        await this.waitAllProjectFirstRuns();
2✔
1310
        if (params.command === CustomCommands.TranspileFile) {
2!
1311
            const result = await this.transpileFile(params.arguments[0]);
2✔
1312
            //back-compat: include `pathAbsolute` property so older vscode versions still work
1313
            (result as any).pathAbsolute = result.srcPath;
2✔
1314
            return result;
2✔
1315
        }
1316
    }
1317

1318
    private async transpileFile(srcPath: string) {
1319
        //wait all program first runs
1320
        await this.waitAllProjectFirstRuns();
2✔
1321
        //find the first project that has this file
1322
        for (let project of this.getProjects()) {
2✔
1323
            if (project.builder.program.hasFile(srcPath)) {
2!
1324
                return project.builder.program.getTranspiledFileContents(srcPath);
2✔
1325
            }
1326
        }
1327
    }
1328

1329
    public dispose() {
1330
        this.loggerSubscription?.();
35✔
1331
        this.validateThrottler.dispose();
35✔
1332
    }
1333
}
1334

1335
export interface Project {
1336
    /**
1337
     * A unique number for this project, generated during this current language server session. Mostly used so we can identify which project is doing logging
1338
     */
1339
    projectNumber: number;
1340
    firstRunPromise: Promise<any>;
1341
    builder: ProgramBuilder;
1342
    /**
1343
     * The path to where the project resides
1344
     */
1345
    projectPath: string;
1346
    /**
1347
     * The path to the workspace where this project resides. A workspace can have multiple projects (by adding a bsconfig.json to each folder).
1348
     */
1349
    workspacePath: string;
1350
    isFirstRunComplete: boolean;
1351
    isFirstRunSuccessful: boolean;
1352
    configFilePath?: string;
1353
    isStandaloneFileProject: boolean;
1354
}
1355

1356
export enum CustomCommands {
1✔
1357
    TranspileFile = 'TranspileFile'
1✔
1358
}
1359

1360
export enum NotificationName {
1✔
1361
    busyStatus = 'busyStatus'
1✔
1362
}
1363

1364
/**
1365
 * Wraps a method. If there's an error (either sync or via a promise),
1366
 * this appends the error's stack trace at the end of the error message so that the connection will
1367
 */
1368
function AddStackToErrorMessage(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
1369
    let originalMethod = descriptor.value;
17✔
1370

1371
    //wrapping the original method
1372
    descriptor.value = function value(...args: any[]) {
17✔
1373
        try {
31✔
1374
            let result = originalMethod.apply(this, args);
31✔
1375
            //if the result looks like a promise, log if there's a rejection
1376
            if (result?.then) {
31!
1377
                return Promise.resolve(result).catch((e: Error) => {
29✔
1378
                    if (e?.stack) {
×
1379
                        e.message = e.stack;
×
1380
                    }
1381
                    return Promise.reject(e);
×
1382
                });
1383
            } else {
1384
                return result;
2✔
1385
            }
1386
        } catch (e: any) {
1387
            if (e?.stack) {
×
1388
                e.message = e.stack;
×
1389
            }
1390
            throw e;
×
1391
        }
1392
    };
1393
}
1394

1395
/**
1396
 * An annotation used to wrap the method in a busyStatus tracking call
1397
 */
1398
function TrackBusyStatus(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
1399
    let originalMethod = descriptor.value;
16✔
1400

1401
    //wrapping the original method
1402
    descriptor.value = function value(this: LanguageServer, ...args: any[]) {
16✔
1403
        return this.busyStatusTracker.run(() => {
92✔
1404
            return originalMethod.apply(this, args);
92✔
1405
        }, originalMethod.name);
1406
    };
1407
}
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

© 2025 Coveralls, Inc