diff --git a/src/LanguageServer.spec.ts b/src/LanguageServer.spec.ts index 0f938773b..ec845ce66 100644 --- a/src/LanguageServer.spec.ts +++ b/src/LanguageServer.spec.ts @@ -838,7 +838,8 @@ describe('LanguageServer', () => { projectDiscoveryExclude: undefined, logLevel: 'info', projectActivationConcurrencyLimit: undefined, - maxWorkerThreads: undefined + maxWorkerThreads: undefined, + lazyProjectActivation: false } } ]); diff --git a/src/LanguageServer.ts b/src/LanguageServer.ts index 24359be58..8a4ad6a09 100644 --- a/src/LanguageServer.ts +++ b/src/LanguageServer.ts @@ -73,6 +73,10 @@ export class LanguageServer { * The default project discovery setting for the language server. Can be overridden by per-workspace settings */ public static enableProjectDiscoveryDefault = true; + /** + * The default lazy project activation setting for the language server. Can be overridden by per-workspace settings + */ + public static lazyProjectActivationDefault = false; /** * The default number of projects that are permitted to activate concurrently. @@ -206,6 +210,9 @@ export class LanguageServer { // this.documents.onDidChangeContent(this.onTextDocumentDidChangeContent.bind(this)); + //whenever a document gets opened (fires before the first onDidChangeContent for that document) + this.documents.onDidOpen(this.onDocumentOpen.bind(this)); + //whenever a document gets closed this.documents.onDidClose(this.onDocumentClose.bind(this)); @@ -555,6 +562,15 @@ export class LanguageServer { await this.projectManager.handleFileChanges(changes); } + @AddStackToErrorMessage + private async onDocumentOpen(event: TextDocumentChangeEvent): Promise { + this.logger.debug('onDocumentOpen', event.document.uri); + + await this.projectManager.handleFileOpen({ + srcPath: util.uriToPath(event.document.uri) + }); + } + @AddStackToErrorMessage private async onDocumentClose(event: TextDocumentChangeEvent): Promise { this.logger.debug('onDocumentClose', event.document.uri); @@ -611,7 +627,8 @@ export class LanguageServer { projectDiscoveryExclude: brightscriptConfig?.languageServer?.projectDiscoveryExclude, logLevel: brightscriptConfig?.languageServer?.logLevel, projectActivationConcurrencyLimit: brightscriptConfig?.languageServer?.projectActivationConcurrencyLimit, - maxWorkerThreads: brightscriptConfig?.languageServer?.maxWorkerThreads + maxWorkerThreads: brightscriptConfig?.languageServer?.maxWorkerThreads, + lazyProjectActivation: brightscriptConfig?.languageServer?.lazyProjectActivation ?? LanguageServer.lazyProjectActivationDefault } }; }) @@ -1083,6 +1100,7 @@ export interface BrightScriptClientConfiguration { projectDiscoveryMaxDepth?: number; projectActivationConcurrencyLimit?: number; maxWorkerThreads?: number; + lazyProjectActivation?: boolean; }; } diff --git a/src/lsp/ProjectManager.spec.ts b/src/lsp/ProjectManager.spec.ts index 226cc3fc5..41bc65e2e 100644 --- a/src/lsp/ProjectManager.spec.ts +++ b/src/lsp/ProjectManager.spec.ts @@ -1565,6 +1565,131 @@ describe('ProjectManager', () => { }); }); + describe('lazy project activation', () => { + const lazyWorkspaceSettings = { + ...workspaceSettings, + languageServer: { + ...workspaceSettings.languageServer, + lazyProjectActivation: true + } + }; + + beforeEach(() => { + fsExtra.outputFileSync(`${rootDir}/project1/bsconfig.json`, ''); + fsExtra.outputFileSync(`${rootDir}/project1/source/main.brs`, `sub main()\nend sub`); + fsExtra.outputFileSync(`${rootDir}/project2/bsconfig.json`, ''); + fsExtra.outputFileSync(`${rootDir}/project2/source/main.brs`, `sub main()\nend sub`); + }); + + async function openFile(srcPath: string, contents: string) { + await manager.handleFileOpen({ srcPath: srcPath }); + await manager.handleFileChanges([{ + srcPath: srcPath, + type: FileChangeType.Changed, + fileContents: contents, + allowStandaloneProject: true + }]); + } + + it('activates every project when the setting is disabled', async () => { + await manager.syncProjects([workspaceSettings]); + expect(manager.projects.map(x => x.projectKey).sort()).to.eql([ + s`${rootDir}/project1/bsconfig.json`, + s`${rootDir}/project2/bsconfig.json` + ]); + expect(manager['dormantProjects'].size).to.eql(0); + }); + + it('leaves projects dormant when none of their files are open', async () => { + await manager.syncProjects([lazyWorkspaceSettings]); + expect(manager.projects).to.be.empty; + expect([...manager['dormantProjects'].keys()].sort()).to.eql([ + s`${rootDir}/project1/bsconfig.json`, + s`${rootDir}/project2/bsconfig.json` + ]); + }); + + it('activates the only project even when the setting is enabled', async () => { + fsExtra.removeSync(`${rootDir}/project2`); + await manager.syncProjects([lazyWorkspaceSettings]); + expect(manager.projects.map(x => x.projectKey)).to.eql([ + s`${rootDir}/project1/bsconfig.json` + ]); + expect(manager['dormantProjects'].size).to.eql(0); + }); + + it('activates a dormant project when one of its files is opened', async () => { + await manager.syncProjects([lazyWorkspaceSettings]); + + await openFile(s`${rootDir}/project1/source/main.brs`, `sub main()\n doesNotExist()\nend sub`); + await manager.onIdle(); + + expect(manager.projects.map(x => x.projectKey)).to.eql([ + s`${rootDir}/project1/bsconfig.json` + ]); + expect([...manager['dormantProjects'].keys()]).to.eql([ + s`${rootDir}/project2/bsconfig.json` + ]); + //the file was handled by the woken project, so no standalone project should exist + expect(manager['standaloneProjects'].size).to.eql(0); + + //the woken project was validated using the in-memory contents of the opened file + const diagnostics = await onNextDiagnostics(); + expectDiagnostics(diagnostics, [ + DiagnosticMessages.cannotFindFunction('doesNotExist').message + ]); + }); + + it('activates projects that have an open file during sync', async () => { + await manager.handleFileOpen({ srcPath: s`${rootDir}/project2/source/main.brs` }); + await manager.syncProjects([lazyWorkspaceSettings]); + expect(manager.projects.map(x => x.projectKey)).to.eql([ + s`${rootDir}/project2/bsconfig.json` + ]); + expect([...manager['dormantProjects'].keys()]).to.eql([ + s`${rootDir}/project1/bsconfig.json` + ]); + }); + + it('creates a standalone project for an open file that no dormant project owns', async () => { + await manager.syncProjects([lazyWorkspaceSettings]); + await openFile(s`${rootDir}/loose.brs`, `sub loose()\nend sub`); + await manager.onIdle(); + expect(manager['dormantProjects'].size).to.eql(2); + expect(manager['standaloneProjects'].size).to.eql(1); + }); + + it('activates dormant projects when the setting gets disabled', async () => { + await manager.syncProjects([lazyWorkspaceSettings]); + expect(manager.projects).to.be.empty; + + await manager.syncProjects([workspaceSettings]); + expect(manager.projects.map(x => x.projectKey).sort()).to.eql([ + s`${rootDir}/project1/bsconfig.json`, + s`${rootDir}/project2/bsconfig.json` + ]); + expect(manager['dormantProjects'].size).to.eql(0); + }); + + it('uses the files array from bsconfig.json to decide which project owns an opened file', async () => { + fsExtra.outputFileSync(`${rootDir}/project2/bsconfig.json`, JSON.stringify({ + rootDir: 'app', + files: ['source/**/*'] + })); + fsExtra.outputFileSync(`${rootDir}/project2/app/source/main.brs`, `sub main()\nend sub`); + await manager.syncProjects([lazyWorkspaceSettings]); + + //this file is outside of project2's rootDir, so it should not wake project2 + await openFile(s`${rootDir}/project2/source/main.brs`, `sub main()\nend sub`); + await manager.onIdle(); + expect(manager['dormantProjects'].has(s`${rootDir}/project2/bsconfig.json`)).to.be.true; + + await openFile(s`${rootDir}/project2/app/source/main.brs`, `sub main()\nend sub`); + await manager.onIdle(); + expect(manager.projects.map(x => x.projectKey)).to.include(s`${rootDir}/project2/bsconfig.json`); + }); + }); + it('completes promise when project is disposed in the middle of a flow', async function () { //this test enables real threading without a warm-up hook, so it pays the full worker-thread //cold-boot cost itself. That boot has been measured at ~18s on macOS CI runners, so give it diff --git a/src/lsp/ProjectManager.ts b/src/lsp/ProjectManager.ts index e78daa4c5..06a911f34 100644 --- a/src/lsp/ProjectManager.ts +++ b/src/lsp/ProjectManager.ts @@ -67,6 +67,17 @@ export class ProjectManager { */ private standaloneProjects = new Map(); + /** + * Projects that were discovered but not activated because none of their files are open in the editor. + * They get activated the first time one of their files is opened. Keyed by projectKey. + */ + private dormantProjects = new Map(); + + /** + * The srcPaths of all documents currently open in the editor + */ + private openDocuments = new Set(); + private documentManager: DocumentManager; public static documentManagerDelay = 150; @@ -118,6 +129,12 @@ export class ProjectManager { const actions = [...event.actions] as DocumentActionWithStatus[]; + //wake up any dormant projects that own a file opened in the editor, so they can handle these actions + //(this must happen before standalone project creation, otherwise those files would get a standalone project instead) + await this.activateDormantProjectsForFiles( + actions.filter(x => x.type === 'set' && x.allowStandaloneProject).map(x => x.srcPath) + ); + 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) { @@ -309,25 +326,37 @@ export class ProjectManager { for (const project of this.projects) { this.removeProject(project); } + for (const projectKey of [...this.dormantProjects.keys()]) { + this.removeDormantProject(projectKey); + } } this.logger.log('syncProjects', workspaceConfigs.map(x => x.workspaceFolder)); const generation = ++this.syncGeneration; this.syncPromise = (async () => { + //keys of projects whose workspace has lazy project activation enabled + const lazyProjectKeys = new Set(); + //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(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 - })); + return discoveredProjects.map(discoveredProject => { + const projectKey = s`${discoveredProject.bsconfigPath ?? discoveredProject.dir}`; + if (workspaceConfig.languageServer.lazyProjectActivation) { + lazyProjectKeys.add(projectKey); + } + return { + name: discoveredProject?.name, + projectKey: projectKey, + projectDir: s`${discoveredProject.dir}`, + bsconfigPath: discoveredProject?.bsconfigPath, + workspaceFolder: s`${workspaceConfig.workspaceFolder}`, + excludePatterns: workspaceConfig.excludePatterns, + enableThreading: workspaceConfig.languageServer.enableThreading + }; + }); }) )).flat(1); @@ -335,6 +364,12 @@ export class ProjectManager { //filter the project paths to only include those that are allowed by the path filterer projectConfigs = this.pathFilterer.filter(projectConfigs, x => x.projectKey); + //leaving projects dormant only helps when there's more than one project + const totalProjectCount = projectConfigs.length; + const canBeDormant = (config: ProjectConfig) => { + return totalProjectCount > 1 && lazyProjectKeys.has(config.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 @@ -343,9 +378,17 @@ export class ProjectManager { } } + //delete dormant projects that are no longer in the list, or that are no longer allowed to be dormant (they get activated below) + for (const dormantProject of [...this.dormantProjects.values()]) { + const config = projectConfigs.find(x => x.projectKey === dormantProject.config.projectKey); + if (!config || !canBeDormant(config)) { + this.removeDormantProject(dormantProject.config.projectKey); + } + } + // skip projects we already have (they're already loaded...no need to reload them) projectConfigs = projectConfigs.filter(x => { - return !this.hasProject(x); + return !this.hasProject(x) && !this.dormantProjects.has(x.projectKey); }); //dedupe by projectKey @@ -356,6 +399,11 @@ export class ProjectManager { ).values() ]; + //leave projects dormant if none of their files are open. They'll be activated once one of their files is opened + projectConfigs = projectConfigs.filter(config => { + return !(canBeDormant(config) && this.tryCreateDormantProject(config)); + }); + // Phase 1: activate projects with concurrency limit (awaited — gates LSP readiness) const activatedProjects: LspProject[] = []; await this.runWithConcurrencyLimit(projectConfigs, this.projectActivationConcurrencyLimit, async (config) => { @@ -514,6 +562,17 @@ export class ProjectManager { projectsToReload.map(x => this.reloadProject(x)) ); } + + //a dormant project's bsconfig.json changed, so recompute which files belong to it (or activate it if one of its files is now open) + for (const dormantProject of [...this.dormantProjects.values()]) { + if (dormantProject.config.bsconfigPath?.toLowerCase() === change.srcPath.toLowerCase()) { + this.removeDormantProject(dormantProject.config.projectKey); + if (!this.tryCreateDormantProject(dormantProject.config)) { + const project = await this.createAndActivateProject(dormantProject.config); + void project.validate(); + } + } + } } /** @@ -521,11 +580,102 @@ export class ProjectManager { */ public async handleFileClose(event: { srcPath: string }) { this.logger.debug(`File was closed. ${event.srcPath}`); + this.openDocuments.delete(util.standardizePath(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(); } + /** + * Handle when a file is opened in the editor. Dormant projects are activated when the file's contents are flushed, + * but we track open files here so that later syncs won't leave the projects for open files dormant. + */ + public async handleFileOpen(event: { srcPath: string }) { + this.logger.debug(`File was opened. ${event.srcPath}`); + this.openDocuments.add(util.standardizePath(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(); + } + + /** + * Build a matcher for the files that belong to a project by resolving its bsconfig.json the same way `ProgramBuilder` does, + * but without finding or loading any of the project's files + */ + private getProjectFilePatterns(config: ProjectConfig) { + let bsconfigPath = config.bsconfigPath ? path.resolve(config.projectDir, config.bsconfigPath) : undefined; + if (!bsconfigPath) { + bsconfigPath = [s`${config.projectDir}/bsconfig.json`, s`${config.projectDir}/brsconfig.json`].find(x => fsExtra.pathExistsSync(x)); + } + const options = util.normalizeAndResolveConfig({ + cwd: bsconfigPath ? path.dirname(bsconfigPath) : config.projectDir, + project: bsconfigPath + }); + const rootDir = util.getRootDir(options); + const globs = rokuDeploy.normalizeFilesArray(options.files).map(x => { + return typeof x === 'string' ? x : x.src; + }); + return { rootDir: rootDir, globs: globs }; + } + + /** + * Register a project as dormant if none of its files are open in the editor + * @returns true if the project was left dormant, false if it needs to be activated + */ + private tryCreateDormantProject(config: ProjectConfig): boolean { + let filePatterns: { rootDir: string; globs: string[] }; + try { + filePatterns = this.getProjectFilePatterns(config); + } catch (e) { + //if we can't resolve the config, activate the project so its errors get reported the normal way + this.logger.debug(`Could not resolve file patterns for '${config.projectKey}', so it will be activated`, e); + return false; + } + const filterer = new PathCollection(filePatterns); + //a file from this project is already open, so it should be activated right away + if ([...this.openDocuments].some(srcPath => filterer.isMatch(srcPath))) { + return false; + } + this.logger.log(`Leaving project dormant until one of its files is opened: "${config.projectKey}"`); + this.dormantProjects.set(config.projectKey, { + config: config, + filterer: filterer, + //register this project's files with the path filterer so changes to them still flow through (just like active projects) + unregisterIncludeList: this.pathFilterer.registerIncludeList(filePatterns.rootDir, filePatterns.globs) + }); + return true; + } + + private removeDormantProject(projectKey: string) { + const dormantProject = this.dormantProjects.get(projectKey); + if (dormantProject) { + dormantProject.unregisterIncludeList(); + this.dormantProjects.delete(projectKey); + } + return dormantProject; + } + + /** + * Activate (and validate) every dormant project that owns at least one of the given files + */ + private async activateDormantProjectsForFiles(srcPaths: string[]) { + if (this.dormantProjects.size === 0 || srcPaths.length === 0) { + return; + } + const configs: ProjectConfig[] = []; + for (const dormantProject of [...this.dormantProjects.values()]) { + if (srcPaths.some(srcPath => dormantProject.filterer.isMatch(srcPath))) { + //remove it synchronously so concurrent flushes don't activate it twice + this.removeDormantProject(dormantProject.config.projectKey); + configs.push(dormantProject.config); + } + } + await this.runWithConcurrencyLimit(configs, this.projectActivationConcurrencyLimit, async (config) => { + this.logger.log(`Activating dormant project because one of its files was opened: "${config.projectKey}"`); + const project = await this.createAndActivateProject(config); + void project.validate(); + }); + } + /** * Given a project, forcibly reload it by removing it and re-adding it */ @@ -1112,6 +1262,9 @@ export class ProjectManager { for (const project of this.projects) { project?.dispose?.(); } + for (const projectKey of [...this.dormantProjects.keys()]) { + this.removeDormantProject(projectKey); + } } } @@ -1161,9 +1314,29 @@ export interface WorkspaceConfig { * additional projects are multiplexed onto existing worker threads rather than spawning new ones. */ maxWorkerThreads?: number; + /** + * When multiple projects are discovered, should projects be left dormant (not parsed or validated) until + * one of their files is opened in the editor? + */ + lazyProjectActivation?: boolean; }; } +/** + * A project that was discovered but has not been activated because none of its files are open + */ +interface DormantProject { + config: ProjectConfig; + /** + * Matches the files that belong to this project (built from its bsconfig.json without loading any files) + */ + filterer: PathCollection; + /** + * Unregisters this project's file patterns from the path filterer + */ + unregisterIncludeList: () => void; +} + interface StandaloneProject extends LspProject { /** * The path to the file that this project represents