forked from rokucommunity/brighterscript
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathProjectManager.ts
More file actions
1009 lines (885 loc) · 40.9 KB
/
ProjectManager.ts
File metadata and controls
1009 lines (885 loc) · 40.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { standardizePath as s, util } from '../util';
import { rokuDeploy } from 'roku-deploy';
import * as path from 'path';
import * as EventEmitter from 'eventemitter3';
import type { LspDiagnostic, LspProject, ProjectConfig } from './LspProject';
import { Project } from './Project';
import { WorkerThreadProject } from './worker/WorkerThreadProject';
import { FileChangeType } from 'vscode-languageserver-protocol';
import type { Hover, Position, Range, Location, SignatureHelp, DocumentSymbol, SymbolInformation, WorkspaceSymbol, CompletionList, CancellationToken } from 'vscode-languageserver-protocol';
import { Deferred } from '../deferred';
import type { DocumentActionWithStatus, FlushEvent } from './DocumentManager';
import { DocumentManager } from './DocumentManager';
import type { FileChange, MaybePromise } from '../interfaces';
import { BusyStatusTracker } from '../BusyStatusTracker';
import * as fastGlob from 'fast-glob';
import { PathCollection, PathFilterer } from './PathFilterer';
import type { Logger, LogLevel } from '../logging';
import { createLogger } from '../logging';
import { Cache } from '../Cache';
import { ActionQueue } from './ActionQueue';
import * as fsExtra from 'fs-extra';
import type { BrightScriptProjectConfiguration } from '../LanguageServer';
const FileChangeTypeLookup = Object.entries(FileChangeType).reduce((acc, [key, value]) => {
acc[value] = key;
acc[key] = value;
return acc;
}, {});
/**
* Manages all brighterscript projects for the language server
*/
export class ProjectManager {
constructor(options?: {
pathFilterer: PathFilterer;
logger?: Logger;
}) {
this.logger = options?.logger ?? createLogger();
this.pathFilterer = options?.pathFilterer ?? new PathFilterer({ logger: options?.logger });
this.documentManager = new DocumentManager({
delay: ProjectManager.documentManagerDelay,
flushHandler: (event) => {
return this.flushDocumentChanges(event).catch(e => console.error(e));
}
});
this.on('validate-begin', (event) => {
this.busyStatusTracker.beginScopedRun(event.project, `validate-project`);
});
this.on('validate-end', (event) => {
void this.busyStatusTracker.endScopedRun(event.project, `validate-project`);
});
}
private pathFilterer: PathFilterer;
private logger: Logger;
/**
* Collection of all projects
*/
public projects: LspProject[] = [];
/**
* Collection of standalone projects. These are projects that are not part of a workspace, but are instead single files.
* All of these are also present in the `projects` collection.
*/
private standaloneProjects = new Map<string, StandaloneProject>();
private documentManager: DocumentManager;
public static documentManagerDelay = 150;
public busyStatusTracker = new BusyStatusTracker<LspProject>();
/**
* Cache for PathCollection instances per project. Avoids recreating PathCollection
* on every document flush, which is wasteful since file patterns only change when a project is reloaded.
*/
private projectFiltererCache = new WeakMap<LspProject, PathCollection>();
/**
* Get or create a cached PathCollection for the given project.
* The filterer is invalidated when the project is removed and garbage collected.
*/
private getProjectFilterer(project: LspProject): PathCollection {
let filterer = this.projectFiltererCache.get(project);
if (!filterer) {
filterer = new PathCollection({
rootDir: project.rootDir,
globs: project.filePatterns
});
this.projectFiltererCache.set(project, filterer);
}
return filterer;
}
/**
* Apply all of the queued document changes. This should only be called as a result of the documentManager flushing changes, and never called manually
* @param event the document changes that have occurred since the last time we applied
*/
@TrackBusyStatus
private async flushDocumentChanges(event: FlushEvent) {
this.logger.info(`flushDocumentChanges`, event?.actions?.map(x => ({
type: x.type,
srcPath: x.srcPath,
allowStandaloneProject: x.allowStandaloneProject
})));
//ensure that we're fully initialized before proceeding
await this.onInitialized();
const actions = [...event.actions] as DocumentActionWithStatus[];
let idSequence = 0;
//add an ID to every action (so we can track which actions were handled by which projects)
for (const action of actions) {
action.id = idSequence++;
}
//apply all of the document actions to each project in parallel
const responses = await Promise.all(this.projects.map(async (project) => {
//wait for this project to finish activating
await project.whenActivated();
const filterer = this.getProjectFilterer(project);
// only include files that are applicable to this specific project (still allow deletes to flow through since they're cheap)
const projectActions = actions.filter(action => {
return (
//if this is a delete, just pass it through because they're cheap to apply
action.type === 'delete' ||
//if this is a set, only pass it through if it's a file that this project cares about
filterer.isMatch(action.srcPath)
);
});
if (projectActions.length > 0) {
const responseActions = await project.applyFileChanges(projectActions);
return responseActions.map(x => ({
project: project,
action: x
}));
}
}));
//create standalone projects for any files not handled by any project
const flatResponses = responses.flat();
for (const action of actions) {
//skip this action if it doesn't support standalone projects
if (!action.allowStandaloneProject || action.type !== 'set') {
continue;
}
//a list of responses that handled this action
const handledResponses = flatResponses.filter(x => x?.action?.id === action.id && x?.action?.status === 'accepted');
//remove any standalone project created for this file since it was handled by a normal project
const normalProjectsThatHandledThisFile = handledResponses.filter(x => !x.project.isStandaloneProject);
if (normalProjectsThatHandledThisFile.length > 0) {
//if there's a standalone project for this file, delete it
if (this.getStandaloneProject(action.srcPath, false)) {
this.logger.debug(
`flushDocumentChanges: removing standalone project because the following normal projects handled the file: '${action.srcPath}', projects:`,
normalProjectsThatHandledThisFile.map(x => util.getProjectLogName(x.project))
);
this.removeStandaloneProject(action.srcPath);
}
// create a standalone project if this action was handled by zero normal projects.
//(safe to call even if there's already a standalone project, won't create dupes)
} else {
//TODO only create standalone projects for files we understand (brightscript, brighterscript, scenegraph xml, etc)
await this.createStandaloneProject(action.srcPath);
}
}
this.logger.info('flushDocumentChanges complete', actions.map(x => ({
type: x.type,
srcPath: x.srcPath,
allowStandaloneProject: x.allowStandaloneProject
})));
}
/**
* Get a standalone project for a given file path
*/
private getStandaloneProject(srcPath: string, standardizePath = true) {
return this.standaloneProjects.get(
standardizePath ? util.standardizePath(srcPath) : srcPath
);
}
/**
* Create a project that validates a single file. This is useful for getting language support for files that don't belong to a project
*/
private async createStandaloneProject(srcPath: string) {
srcPath = util.standardizePath(srcPath);
//if we already have a standalone project with this path, do nothing because it already exists
if (this.getStandaloneProject(srcPath, false)) {
this.logger.log('createStandaloneProject skipping because we already have one for this path');
return;
}
this.logger.log(`Creating standalone project for '${srcPath}'`);
const projectNumber = ProjectManager.projectNumberSequence++;
const rootDir = path.join(__dirname, `standalone-project-${projectNumber}`);
const projectConfig: ProjectConfig = {
//these folders don't matter for standalone projects
workspaceFolder: rootDir,
projectDir: rootDir,
//there's no bsconfig.json for standalone projects, so projectKey is the same as the dir
projectKey: rootDir,
bsconfigPath: undefined,
enableThreading: false,
projectNumber: projectNumber,
files: [{
src: srcPath,
dest: 'source/standalone.brs'
}]
};
const project = this.constructProject(projectConfig) as StandaloneProject;
project.srcPath = srcPath;
project.isStandaloneProject = true;
this.standaloneProjects.set(srcPath, project);
await this.activateProject(project, projectConfig);
}
private removeStandaloneProject(srcPath: string) {
srcPath = util.standardizePath(srcPath);
const project = this.getStandaloneProject(srcPath, false);
if (project) {
if (project.srcPath === srcPath) {
this.logger.debug(`Removing standalone project for file '${srcPath}'`);
this.removeProject(project);
this.standaloneProjects.delete(srcPath);
}
}
}
/**
* A promise that's set when a sync starts, and resolved when the sync is complete
*/
private syncPromise: Promise<void> | undefined;
private firstSync = new Deferred();
/**
* Get a promise that resolves when this manager is finished initializing
*/
public onInitialized() {
return Promise.allSettled([
//wait for the first sync to finish
this.firstSync.promise,
//make sure we're not in the middle of a sync
this.syncPromise,
//make sure all projects are activated
...this.projects.map(x => x.whenActivated())
]);
}
/**
* Get a promise that resolves when the project manager is idle (no pending work)
*/
public async onIdle() {
await this.onInitialized();
//There are race conditions where the fileChangesQueue will become idle, but that causes the documentManager
//to start a new flush. So we must keep waiting until everything is idle
while (!this.documentManager.isIdle || !this.fileChangesQueue.isIdle) {
this.logger.debug('onIdle', { documentManagerIdle: this.documentManager.isIdle, fileChangesQueueIdle: this.fileChangesQueue.isIdle });
await Promise.allSettled([
//make sure all pending file changes have been flushed
this.documentManager.onIdle(),
//wait for the file changes queue to be idle
this.fileChangesQueue.onIdle()
]);
}
this.logger.info('onIdle debug', { documentManagerIdle: this.documentManager.isIdle, fileChangesQueueIdle: this.fileChangesQueue.isIdle });
}
/**
* Given a list of all desired projects, create any missing projects and destroy and projects that are no longer available
* Treat workspaces that don't have a bsconfig.json as a project.
* Handle situations where bsconfig.json files were added or removed (to elevate/lower workspaceFolder projects accordingly)
* Leave existing projects alone if they are not affected by these changes
* @param workspaceConfigs an array of workspaces
*/
@TrackBusyStatus
public async syncProjects(workspaceConfigs: WorkspaceConfig[], forceReload = false) {
//if we're force reloading, destroy all projects and start fresh
if (forceReload) {
this.logger.log('syncProjects: forceReload is true so removing all existing projects');
for (const project of this.projects) {
this.removeProject(project);
}
}
this.logger.log('syncProjects', workspaceConfigs.map(x => x.workspaceFolder));
this.syncPromise = (async () => {
//build a list of unique projects across all workspace folders
let projectConfigs = (await Promise.all(
workspaceConfigs.map(async workspaceConfig => {
const discoveredProjects = await this.discoverProjectsForWorkspace(workspaceConfig);
return discoveredProjects.map<ProjectConfig>(discoveredProject => ({
name: discoveredProject?.name,
projectKey: s`${discoveredProject.bsconfigPath ?? discoveredProject.dir}`,
projectDir: s`${discoveredProject.dir}`,
bsconfigPath: discoveredProject?.bsconfigPath,
workspaceFolder: s`${workspaceConfig.workspaceFolder}`,
excludePatterns: workspaceConfig.excludePatterns,
enableThreading: workspaceConfig.languageServer.enableThreading
}));
})
)).flat(1);
//TODO handle when a project came from the workspace config .projects array (it should probably never be filtered out)
//filter the project paths to only include those that are allowed by the path filterer
projectConfigs = this.pathFilterer.filter(projectConfigs, x => x.projectKey);
//delete projects not represented in the list
for (const project of this.projects) {
//we can't find this existing project in our new list, so scrap it
if (!projectConfigs.find(x => x.projectKey === project.projectKey)) {
this.removeProject(project);
}
}
// skip projects we already have (they're already loaded...no need to reload them)
projectConfigs = projectConfigs.filter(x => {
return !this.hasProject(x);
});
//dedupe by projectKey
projectConfigs = [
...projectConfigs.reduce(
(acc, x) => acc.set(x.projectKey, x),
new Map<string, typeof projectConfigs[0]>()
).values()
];
//create missing projects
await Promise.all(
projectConfigs.map(async (config) => {
await this.createAndActivateProject(config);
})
);
//mark that we've completed our first sync
this.firstSync.tryResolve();
})();
//return the sync promise
return this.syncPromise;
}
private fileChangesQueue = new ActionQueue({
maxActionDuration: 45_000
});
public handleFileChanges(changes: FileChange[]): Promise<void> {
this.logger.debug('handleFileChanges', changes.map(x => `${FileChangeTypeLookup[x.type]}: ${x.srcPath}`));
//this function should NOT be marked as async, because typescript wraps the body in an async call sometimes. These need to be registered synchronously
return this.fileChangesQueue.run(async (changes) => {
//wait for any pending syncs to finish
await this.onInitialized();
return this._handleFileChanges(changes);
}, changes);
}
/**
* Handle when files or directories are added, changed, or deleted in the workspace.
* This is safe to call any time. Changes will be queued and flushed at the correct times
*/
private async _handleFileChanges(changes: FileChange[]) {
//normalize srcPath for all changes
for (const change of changes) {
change.srcPath = util.standardizePath(change.srcPath);
}
//filter any changes that are not allowed by the path filterer
changes = this.pathFilterer.filter(changes, x => x.srcPath);
this.logger.debug('handleFileChanges -> filtered', changes.map(x => `${FileChangeTypeLookup[x.type]}: ${x.srcPath}`));
//process all file changes in parallel
await Promise.all(changes.map(async (change) => {
await this.handleFileChange(change);
}));
}
/**
* Handle a single file change. If the file is a directory, this will recursively read all files in the directory and call `handleFileChanges` again
*/
private async handleFileChange(change: FileChange) {
if (change.type === FileChangeType.Deleted) {
//mark this document or directory as deleted
this.documentManager.delete(change.srcPath);
//file added or changed
} else {
//if this is a new directory, read all files recursively and register those as file changes too
if (util.isDirectorySync(change.srcPath)) {
const files = await fastGlob('**/*', {
cwd: change.srcPath,
onlyFiles: true,
absolute: true
});
//pipe all files found recursively in the new directory through this same function so they can be processed correctly
await Promise.all(files.map((srcPath) => {
return this.handleFileChange({
srcPath: util.standardizePath(srcPath),
type: FileChangeType.Changed,
allowStandaloneProject: change.allowStandaloneProject
});
}));
//this is a new file. set the file contents
} else {
this.documentManager.set({
srcPath: change.srcPath,
fileContents: change.fileContents,
allowStandaloneProject: change.allowStandaloneProject
});
}
}
//reload any projects whose bsconfig.json was changed
const projectsToReload = this.projects.filter(project => {
//this is a path to a bsconfig.json file
if (project.bsconfigPath?.toLowerCase() === change.srcPath.toLowerCase()) {
//fetch file contents if we don't already have them
if (!change.fileContents) {
try {
change.fileContents = fsExtra.readFileSync(project.bsconfigPath).toString();
} finally { }
}
///the bsconfig contents have changed since we last saw it, so reload this project
if (project.bsconfigFileContents !== change.fileContents) {
return true;
}
}
return false;
});
if (projectsToReload.length > 0) {
await Promise.all(
projectsToReload.map(x => this.reloadProject(x))
);
}
}
/**
* Handle when a file is closed in the editor (this mostly just handles removing standalone projects)
*/
public async handleFileClose(event: { srcPath: string }) {
this.logger.debug(`File was closed. ${event.srcPath}`);
this.removeStandaloneProject(event.srcPath);
//most other methods on this class are async, might as well make this one async too for consistency and future expansion
await Promise.resolve();
}
/**
* Given a project, forcibly reload it by removing it and re-adding it
*/
private async reloadProject(project: LspProject) {
this.logger.log('Reloading project', { projectPath: project.projectKey });
this.removeProject(project);
project = await this.createAndActivateProject(project.activateOptions);
}
/**
* Get all the semantic tokens for the given file
* @returns an array of semantic tokens
*/
@TrackBusyStatus
public async getSemanticTokens(options: { srcPath: string }) {
//wait for all pending syncs to finish
await this.onIdle();
let result = await util.promiseRaceMatch(
this.projects.map(x => x.getSemanticTokens(options)),
//keep the first non-falsey result
(result) => result?.length > 0
);
return result;
}
/**
* Get a string containing the transpiled contents of the file at the given path
* @returns the transpiled contents of the file as a string
*/
@TrackBusyStatus
public async transpileFile(options: { srcPath: string }) {
//wait for all pending syncs to finish
await this.onIdle();
let result = await util.promiseRaceMatch(
this.projects.map(x => x.transpileFile(options)),
//keep the first non-falsey result
(result) => !!result
);
return result;
}
/**
* Get the completions for the given position in the file
*/
@TrackBusyStatus
public async getCompletions(options: { srcPath: string; position: Position; cancellationToken?: CancellationToken }): Promise<CompletionList> {
await this.onIdle();
//if the request has been cancelled since originally requested due to idle time being slow, skip the rest of the wor
if (options?.cancellationToken?.isCancellationRequested) {
this.logger.debug('ProjectManager getCompletions cancelled', options);
return;
}
this.logger.debug('ProjectManager getCompletions', options);
//Ask every project for results, keep whichever one responds first that has a valid response
let result = await util.promiseRaceMatch(
this.projects.map(x => x.getCompletions(options)),
//keep the first non-falsey result
(result) => result?.items?.length > 0
);
return result;
}
/**
* Get the hover information for the given position in the file. If multiple projects have hover information, the projects will be raced and
* the fastest result will be returned
* @returns the hover information or undefined if no hover information was found
*/
@TrackBusyStatus
public async getHover(options: { srcPath: string; position: Position }): Promise<Hover> {
//wait for all pending syncs to finish
await this.onIdle();
//Ask every project for hover info, keep whichever one responds first that has a valid response
let hover = await util.promiseRaceMatch(
this.projects.map(x => x.getHover(options)),
//keep the first set of non-empty results
(result) => result?.length > 0
);
return hover?.[0];
}
/**
* Get the definition for the symbol at the given position in the file
* @returns a list of locations where the symbol under the position is defined in the project
*/
@TrackBusyStatus
public async getDefinition(options: { srcPath: string; position: Position }): Promise<Location[]> {
//wait for all pending syncs to finish
await this.onIdle();
//TODO should we merge definitions across ALL projects? or just return definitions from the first project we found
//Ask every project for definition info, keep whichever one responds first that has a valid response
let result = await util.promiseRaceMatch(
this.projects.map(x => x.getDefinition(options)),
//keep the first non-falsey result
(result) => !!result
);
return result;
}
@TrackBusyStatus
public async getSignatureHelp(options: { srcPath: string; position: Position }): Promise<SignatureHelp> {
//wait for all pending syncs to finish
await this.onIdle();
//Ask every project for definition info, keep whichever one responds first that has a valid response
let signatures = await util.promiseRaceMatch(
this.projects.map(x => x.getSignatureHelp(options)),
//keep the first non-falsey result
(result) => !!result
);
if (signatures?.length > 0) {
const activeSignature = signatures.length > 0 ? 0 : undefined;
const activeParameter = activeSignature >= 0 ? signatures[activeSignature]?.index : undefined;
let result: SignatureHelp = {
signatures: signatures.map((s) => s.signature),
activeSignature: activeSignature,
activeParameter: activeParameter
};
return result;
}
}
@TrackBusyStatus
public async getDocumentSymbol(options: { srcPath: string }): Promise<DocumentSymbol[]> {
//wait for all pending syncs to finish
await this.onIdle();
//Ask every project for definition info, keep whichever one responds first that has a valid response
let result = await util.promiseRaceMatch(
this.projects.map(x => x.getDocumentSymbol(options)),
//keep the first non-falsey result
(result) => !!result
);
return result;
}
@TrackBusyStatus
public async getWorkspaceSymbol(): Promise<WorkspaceSymbol[]> {
//wait for all pending syncs to finish
await this.onIdle();
//Ask every project for definition info, keep whichever one responds first that has a valid response
let responses = await Promise.allSettled(
this.projects.map(x => x.getWorkspaceSymbol())
);
let results = responses
//keep all symbol results
.map((x) => {
return x.status === 'fulfilled' ? x.value : [];
})
//flatten the array
.flat()
//throw out nulls
.filter(x => !!x);
// Remove duplicates
const allSymbols = Object.values(
results.reduce((map, symbol) => {
const key = symbol.location.uri + symbol.name;
map[key] = symbol;
return map;
}, {})
);
return allSymbols as SymbolInformation[];
}
@TrackBusyStatus
public async getReferences(options: { srcPath: string; position: Position }): Promise<Location[]> {
//wait for all pending syncs to finish
await this.onIdle();
//Ask every project for definition info, keep whichever one responds first that has a valid response
let result = await util.promiseRaceMatch(
this.projects.map(x => x.getReferences(options)),
//keep the first non-falsey result
(result) => !!result
);
return result ?? [];
}
@TrackBusyStatus
public async getCodeActions(options: { srcPath: string; range: Range }) {
//wait for all pending syncs to finish
await this.onIdle();
//Ask every project for definition info, keep whichever one responds first that has a valid response
let result = await util.promiseRaceMatch(
this.projects.map(x => x.getCodeActions(options)),
//keep the first non-falsey result
(result) => !!result
);
return result;
}
/**
* Scan a given workspace for all `bsconfig.json` files. If at least one is found, then only folders who have bsconfig.json are returned.
* If none are found, then the workspaceFolder itself is treated as a project
*/
private async discoverProjectsForWorkspace(workspaceConfig: WorkspaceConfig): Promise<DiscoveredProject[]> {
//config may provide a list of project paths. If we have these, no other discovery is permitted
if (Array.isArray(workspaceConfig.projects) && workspaceConfig.projects.length > 0) {
this.logger.debug(`Using project paths from workspace config`, workspaceConfig.projects);
const projectConfigs = workspaceConfig.projects.reduce<DiscoveredProject[]>((acc, project) => {
//skip this project if it's disabled or we don't have a path
if (project.disabled || !project.path) {
return acc;
}
//ensure the project path is absolute
if (!path.isAbsolute(project.path)) {
project.path = path.resolve(workspaceConfig.workspaceFolder, project.path);
}
//skip this project if the path does't exist
if (!fsExtra.existsSync(project.path)) {
return acc;
}
//if the project is a directory
if (fsExtra.statSync(project.path).isDirectory()) {
acc.push({
name: project.name,
bsconfigPath: undefined,
dir: project.path
});
//it's a path to a file (hopefully bsconfig.json)
} else {
acc.push({
name: project.name,
dir: path.dirname(project.path),
bsconfigPath: project.path
});
}
return acc;
}, []);
//if we didn't find any valid project paths, log a warning. having zero projects is acceptable, it typically means the user wanted to disable discovery or
//disabled all their projects on purpose
if (projectConfigs.length === 0) {
this.logger.warn(`No valid project paths found in workspace config`, JSON.stringify(workspaceConfig.projects, null, 4));
}
return projectConfigs;
}
//automatic discovery disabled?
if (!workspaceConfig.languageServer.enableProjectDiscovery) {
return [{
dir: workspaceConfig.workspaceFolder
}];
}
//get the list of exclude patterns, negate them so they actually work like excludes), and coerce to forward slashes since that's what fast-glob expects
const excludePatterns = (workspaceConfig.excludePatterns ?? []).map(x => s`!${x}`.replace(/[\\/]+/g, '/'));
let files = await fastGlob(['**/bsconfig.json', ...excludePatterns], {
cwd: workspaceConfig.workspaceFolder,
followSymbolicLinks: false,
absolute: true,
onlyFiles: true,
deep: workspaceConfig.languageServer.projectDiscoveryMaxDepth ?? 15
});
//filter the files to only include those that are allowed by the path filterer
files = this.pathFilterer.filter(files);
//if we found at least one bsconfig.json, then ALL projects must have a bsconfig.json.
if (files.length > 0) {
return files.map(file => ({
dir: s`${path.dirname(file)}`,
bsconfigPath: s`${file}`
}));
}
//look for roku project folders
let rokuLikeDirs = (await Promise.all(
//find all folders containing a `manifest` file
(await fastGlob(['**/manifest', ...excludePatterns], {
cwd: workspaceConfig.workspaceFolder,
followSymbolicLinks: false,
absolute: true,
onlyFiles: true,
deep: workspaceConfig.languageServer.projectDiscoveryMaxDepth ?? 15
})).map(async manifestEntry => {
const manifestDir = path.dirname(manifestEntry);
//TODO validate that manifest is a Roku manifest
const files = await rokuDeploy.getFilePaths([
'source/**/*.{brs,bs}',
...excludePatterns
], manifestDir);
if (files.length > 0) {
return s`${manifestDir}`;
}
})
//throw out nulls
)).filter(x => !!x);
//throw out any directories that are not allowed by the path filterer
rokuLikeDirs = this.pathFilterer.filter(rokuLikeDirs, srcPath => srcPath);
if (rokuLikeDirs.length > 0) {
return rokuLikeDirs.map(file => ({
dir: file
}));
}
//treat the workspace folder as a brightscript project itself
return [{
dir: workspaceConfig.workspaceFolder
}];
}
/**
* Returns true if we have this project, or false if we don't
* @returns true if the project exists, or false if it doesn't
*/
private hasProject(config: Partial<ProjectConfig>) {
return !!this.getProject(config);
}
/**
* Get a project with the specified path
* @param param path to the project or an obj that has `projectPath` prop
* @returns a project, or undefined if no project was found
*/
private getProject(param: string | Partial<ProjectConfig>) {
const projectKey = util.standardizePath(
(typeof param === 'string') ? param : (param?.projectKey ?? param?.bsconfigPath ?? param?.projectDir)
);
if (!projectKey) {
return;
}
return this.projects.find(x => x.projectKey === projectKey);
}
/**
* Remove a project from the language server
*/
private removeProject(project: LspProject) {
const idx = this.projects.findIndex(x => x.projectKey === project?.projectKey);
if (idx > -1) {
this.logger.log('Removing project', { projectKey: project.projectKey, projectNumber: project.projectNumber });
this.projects.splice(idx, 1);
}
//anytime we remove a project, we should emit an event that clears all of its diagnostics
this.emit('diagnostics', { project: project, diagnostics: [] });
project?.dispose();
this.busyStatusTracker.endAllRunsForScope(project);
}
/**
* A unique project counter to help distinguish log entries in lsp mode
*/
private static projectNumberSequence = 0;
private static projectNumberCache = new Cache<string, number>();
/**
* Get a projectNumber for a given config. Try to reuse project numbers when we've seen this project before
* - If the config already has one, use that.
* - If we've already seen this config before, use the same project number as before
*/
private getProjectNumber(config: ProjectConfig) {
if (config.projectNumber !== undefined) {
return config.projectNumber;
}
const key = s`${config.projectKey}` + '-' + s`${config.workspaceFolder}` + '-' + s`${config.bsconfigPath}`;
return ProjectManager.projectNumberCache.getOrAdd(key, () => {
return ProjectManager.projectNumberSequence++;
});
}
/**
* Constructs a project for the given config. Just makes the project, doesn't activate it
* @returns a new project, or the existing project if one already exists with this config info
*/
private constructProject(config: ProjectConfig): LspProject {
//skip this project if we already have it
if (this.hasProject(config)) {
return this.getProject(config);
}
config.projectNumber = this.getProjectNumber(config);
let project: LspProject = config.enableThreading
? new WorkerThreadProject({
logger: this.logger.createLogger()
})
: new Project({
logger: this.logger.createLogger()
});
this.logger.log(`Created project #${config.projectNumber} for: "${config.projectKey}" (${config.enableThreading ? 'worker thread' : 'main thread'})`);
this.projects.push(project);
//pipe all project-specific events through our emitter, and include the project reference
project.on('all', (eventName, data) => {
this.emit(eventName as any, {
...data,
project: project
} as any);
});
return project;
}
/**
* Constructs a project for the given config
* @returns a new project, or the existing project if one already exists with this config info
*/
@TrackBusyStatus
private async createAndActivateProject(config: ProjectConfig): Promise<LspProject> {
//skip this project if we already have it
if (this.hasProject(config)) {
return this.getProject(config.projectKey);
}
const project = this.constructProject(config);
await this.activateProject(project, config);
return project;
}
@TrackBusyStatus
private async activateProject(project: LspProject, config: ProjectConfig) {
this.logger.debug('Activating project', util.getProjectLogName(project), {
projectPath: config?.projectKey,
bsconfigPath: config.bsconfigPath
});
await project.activate(config);
//send an event to indicate that this project has been activated
this.emit('project-activate', { project: project });
//register this project's list of files with the path filterer
const unregister = this.pathFilterer.registerIncludeList(project.rootDir, project.filePatterns);
project.disposables.push({ dispose: unregister });
}
public on(eventName: 'validate-begin', handler: (data: { project: LspProject }) => MaybePromise<void>);
public on(eventName: 'validate-end', handler: (data: { project: LspProject }) => MaybePromise<void>);
public on(eventName: 'critical-failure', handler: (data: { project: LspProject; message: string }) => MaybePromise<void>);
public on(eventName: 'project-activate', handler: (data: { project: LspProject }) => MaybePromise<void>);
public on(eventName: 'diagnostics', handler: (data: { project: LspProject; diagnostics: LspDiagnostic[] }) => MaybePromise<void>);
public on(eventName: string, handler: (payload: any) => MaybePromise<void>) {
this.emitter.on(eventName, handler as any);
return () => {
this.emitter.removeListener(eventName, handler as any);
};
}
private emit(eventName: 'validate-begin', data: { project: LspProject });
private emit(eventName: 'validate-end', data: { project: LspProject });
private emit(eventName: 'critical-failure', data: { project: LspProject; message: string });
private emit(eventName: 'project-activate', data: { project: LspProject });
private emit(eventName: 'diagnostics', data: { project: LspProject; diagnostics: LspDiagnostic[] });
private async emit(eventName: string, data?) {
//emit these events on next tick, otherwise they will be processed immediately which could cause issues
await util.sleep(0);
this.emitter.emit(eventName, data);
}
private emitter = new EventEmitter();
public dispose() {
this.emitter.removeAllListeners();
for (const project of this.projects) {
project?.dispose?.();
}
}
}
export interface WorkspaceConfig {
/**
* Absolute path to the folder where the workspace resides
*/
workspaceFolder: string;
/**
* A list of glob patterns used to _exclude_ files from various bsconfig searches
*/
excludePatterns?: string[];
/**
* A list of project paths that should be used to create projects in place of discovery.
*/
projects?: BrightScriptProjectConfiguration[];
/**
* Language server configuration options
*/
languageServer: {
/**
* Should the projects in this workspace be run in their own dedicated worker threads, or all run on the main thread
*/
enableThreading: boolean;
/**
* Should the language server automatically discover projects in this workspace?
*/
enableProjectDiscovery: boolean;
/**
* A list of glob patterns used to _exclude_ files from project discovery
*/
projectDiscoveryExclude?: Record<string, boolean>;
/**
* The log level to use for this workspace
*/
logLevel?: LogLevel | string;
/**
* Maximum depth to search for Roku projects
*/
projectDiscoveryMaxDepth?: number;
};
}
interface StandaloneProject extends LspProject {
/**
* The path to the file that this project represents
*/
srcPath: string;
}
/**
* An annotation used to wrap the method in a busyStatus tracking call
*/
function TrackBusyStatus(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
let originalMethod = descriptor.value;
//wrapping the original method
descriptor.value = function value(this: ProjectManager, ...args: any[]) {
return this.busyStatusTracker.run(() => {
return originalMethod.apply(this, args);