diff --git a/README.md b/README.md index 592b29376..7c1515276 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ BrighterScript adds several new features to the BrightScript language such as na - Full BrighterScript support for syntax checking, validation, and intellisense is available within the [Brightscript Language](https://marketplace.visualstudio.com/items?itemName=celsoaf.brightscript) VSCode extension. + - Configure the language server behavior using [language server settings](https://github.com/rokucommunity/brighterscript/blob/master/docs/language-server.md). + - And if it's not enough, the [plugin API](https://github.com/rokucommunity/brighterscript/blob/master/docs/plugins.md) allows extending the compiler to provide extra diagnostics or transformations. ## Who uses Brighterscript? diff --git a/docs/language-server.md b/docs/language-server.md new file mode 100644 index 000000000..e58c1ce37 --- /dev/null +++ b/docs/language-server.md @@ -0,0 +1,81 @@ +# Language Server Settings + +The BrighterScript language server provides several configuration options to customize its behavior for your project. + +## Configuration + +All language server settings are configured under the `brightscript.languageServer` section in your VS Code settings. + +### projectDiscoveryExclude + +The `projectDiscoveryExclude` setting allows you to exclude files and directories from project discovery using glob patterns. This is useful when you want to prevent the language server from creating projects for certain directories or files. + +**Type:** `Record` + +**Default:** `undefined` + +**Example:** +```json +{ + "brightscript.languageServer.projectDiscoveryExclude": { + "**/test/**": true, + "**/node_modules/**": true, + "**/.build/**": true, + "**/dist/**": true + } +} +``` + +This setting works similarly to VS Code's `files.exclude` setting but is specifically for controlling which files are considered during project discovery. + +### files.watcherExclude Support + +The language server now automatically respects VS Code's built-in `files.watcherExclude` setting. This setting allows you to exclude files from being watched for changes, which can improve performance in large projects. + +**Type:** `Record` + +**Example:** +```json +{ + "files.watcherExclude": { + "**/tmp/**": true, + "**/cache/**": true, + "**/node_modules/**": true, + "**/.git/objects/**": true + } +} +``` + +Files matching patterns in `files.watcherExclude` will be ignored during file watching, which can help reduce system resource usage and improve performance. + +## How Exclusion Works + +The language server uses multiple exclusion sources to determine which files to process: + +1. **VS Code's built-in exclusions:** + - `files.exclude` - Files excluded from VS Code's file explorer + - `files.watcherExclude` - Files excluded from file watching + - `search.exclude` - Files excluded from search operations + +2. **BrighterScript-specific exclusions:** + - `brightscript.languageServer.projectDiscoveryExclude` - Files excluded from project discovery + +3. **Standard exclusions:** + - `.gitignore` patterns + - Common directories like `node_modules`, `.git`, `out`, `.roku-deploy-staging` + +All these exclusion patterns work together to provide comprehensive control over which files the language server processes. + +## Performance Tips + +For better performance in large projects, consider excluding: + +- Build output directories (`**/dist/**`, `**/build/**`, `**/out/**`) +- Test files that don't need language server analysis (`**/test/**`, `**/*.spec.*`) +- Temporary directories (`**/tmp/**`, `**/cache/**`) +- Package dependencies (`**/node_modules/**`) +- Version control directories (`**/.git/**`) + +## Compatibility + +Both `projectDiscoveryExclude` and `files.watcherExclude` support work with existing exclusion mechanisms and maintain backward compatibility with existing configurations. \ No newline at end of file diff --git a/src/LanguageServer.spec.ts b/src/LanguageServer.spec.ts index 1a65e937a..96a4d0b23 100644 --- a/src/LanguageServer.spec.ts +++ b/src/LanguageServer.spec.ts @@ -544,14 +544,179 @@ describe('LanguageServer', () => { languageServer: { enableThreading: false, enableProjectDiscovery: true, - logLevel: 'info', - projectDiscoveryMaxDepth: 15 + projectDiscoveryMaxDepth: 15, + projectDiscoveryExclude: undefined, + logLevel: 'info' } } ]); }); }); + describe('projectDiscoveryExclude and files.watcherExclude', () => { + it('includes projectDiscoveryExclude in workspace configuration', async () => { + const projectDiscoveryExclude = { + '**/test/**': true, + 'node_modules/**': true + }; + + sinon.stub(server as any, 'getClientConfiguration').callsFake((workspaceFolder, section) => { + if (section === 'brightscript') { + return Promise.resolve({ + languageServer: { + projectDiscoveryExclude: projectDiscoveryExclude + } + }); + } + return Promise.resolve({}); + }); + + server.run(); + const configs = await server['getWorkspaceConfigs'](); + expect(configs[0].languageServer.projectDiscoveryExclude).to.deep.equal(projectDiscoveryExclude); + }); + + it('includes files.watcherExclude in workspace exclude patterns', async () => { + sinon.stub(server as any, 'getClientConfiguration').callsFake((workspaceFolder, section) => { + if (section === 'files') { + return Promise.resolve({ + exclude: { 'node_modules': true }, + watcherExclude: { + '**/tmp/**': true, + '**/cache/**': true + } + }); + } + return Promise.resolve({}); + }); + + server.run(); + const excludeGlobs = await server['getWorkspaceExcludeGlobs'](workspaceFolders[0]); + expect(excludeGlobs).to.include('**/tmp/**'); + expect(excludeGlobs).to.include('**/cache/**'); + }); + + it('includes projectDiscoveryExclude in workspace exclude patterns', async () => { + const projectDiscoveryExclude = { + '**/test/**': true, + '**/node_modules/**': true, + '**/.build/**': true + }; + + sinon.stub(server as any, 'getClientConfiguration').callsFake((workspaceFolder, section) => { + if (section === 'brightscript') { + return Promise.resolve({ + languageServer: { + projectDiscoveryExclude: projectDiscoveryExclude + } + }); + } + return Promise.resolve({}); + }); + + server.run(); + const excludeGlobs = await server['getWorkspaceExcludeGlobs'](workspaceFolders[0]); + expect(excludeGlobs).to.include('**/test/**'); + expect(excludeGlobs).to.include('**/node_modules/**'); + expect(excludeGlobs).to.include('**/.build/**'); + }); + + it('handles undefined projectDiscoveryExclude without crashing', async () => { + sinon.stub(server as any, 'getClientConfiguration').callsFake((workspaceFolder, section) => { + if (section === 'brightscript') { + return Promise.resolve({ + languageServer: { + // projectDiscoveryExclude is undefined + } + }); + } + return Promise.resolve({}); + }); + + server.run(); + const configs = await server['getWorkspaceConfigs'](); + expect(configs[0].languageServer.projectDiscoveryExclude).to.be.undefined; + + // Should not crash during pathFilterer rebuild + await server['rebuildPathFilterer'](); + }); + + it('handles undefined files.watcherExclude without crashing', async () => { + sinon.stub(server as any, 'getClientConfiguration').callsFake((workspaceFolder, section) => { + if (section === 'files') { + return Promise.resolve({ + exclude: { '**/node_modules/**/*': true } + // watcherExclude is undefined + }); + } + return Promise.resolve({}); + }); + + server.run(); + const excludeGlobs = await server['getWorkspaceExcludeGlobs'](workspaceFolders[0]); + expect(excludeGlobs).to.eql([ + '**/node_modules/**/*' + ]); + }); + + it('handles null/undefined configuration sections without crashing', async () => { + sinon.stub(server as any, 'getClientConfiguration').callsFake((workspaceFolder, section) => { + return Promise.resolve(null); + }); + + server.run(); + const configs = await server['getWorkspaceConfigs'](); + expect(configs[0].languageServer.projectDiscoveryExclude).to.be.undefined; + + const excludeGlobs = await server['getWorkspaceExcludeGlobs'](workspaceFolders[0]); + expect(excludeGlobs).to.eql([]); + }); + + it('handles empty objects for configuration sections without crashing', async () => { + sinon.stub(server as any, 'getClientConfiguration').callsFake((workspaceFolder, section) => { + return Promise.resolve({}); + }); + + server.run(); + const configs = await server['getWorkspaceConfigs'](); + expect(configs[0].languageServer.projectDiscoveryExclude).to.be.undefined; + + const excludeGlobs = await server['getWorkspaceExcludeGlobs'](workspaceFolders[0]); + expect(excludeGlobs).to.eql([]); + }); + + it('handles mixed defined/undefined settings without crashing', async () => { + sinon.stub(server as any, 'getClientConfiguration').callsFake((workspaceFolder, section) => { + if (section === 'brightscript') { + return Promise.resolve({ + languageServer: { + projectDiscoveryExclude: { + '**/test/**/*': true + } + } + }); + } else if (section === 'files') { + return Promise.resolve({ + exclude: { '**/excludeMe/**/*': true } + // watcherExclude is undefined + }); + } + return Promise.resolve({}); + }); + + server.run(); + + const excludeGlobs = await server['getWorkspaceExcludeGlobs'](workspaceFolders[0]); + expect(excludeGlobs).to.eql([ + '**/excludeMe/**/*', + '**/test/**/*' + ]); + + // Should not crash during pathFilterer rebuild + await server['rebuildPathFilterer'](); + }); + }); + describe('onInitialize', () => { it('sets capabilities', async () => { server['hasConfigurationCapability'] = false; diff --git a/src/LanguageServer.ts b/src/LanguageServer.ts index 3ef029983..ad73f0888 100644 --- a/src/LanguageServer.ts +++ b/src/LanguageServer.ts @@ -430,12 +430,14 @@ export class LanguageServer { return { workspaceFolder: workspaceFolder, excludePatterns: await this.getWorkspaceExcludeGlobs(workspaceFolder), - projects: this.normalizeProjectPaths(workspaceFolder, brightscriptConfig.projects), + projects: this.normalizeProjectPaths(workspaceFolder, brightscriptConfig?.projects), languageServer: { - enableThreading: brightscriptConfig.languageServer?.enableThreading ?? LanguageServer.enableThreadingDefault, - enableProjectDiscovery: brightscriptConfig.languageServer?.enableProjectDiscovery ?? LanguageServer.enableProjectDiscoveryDefault, - logLevel: brightscriptConfig?.languageServer?.logLevel, - projectDiscoveryMaxDepth: brightscriptConfig?.languageServer?.projectDiscoveryMaxDepth ?? 15 + enableThreading: brightscriptConfig?.languageServer?.enableThreading ?? LanguageServer.enableThreadingDefault, + enableProjectDiscovery: brightscriptConfig?.languageServer?.enableProjectDiscovery ?? LanguageServer.enableProjectDiscoveryDefault, + projectDiscoveryMaxDepth: brightscriptConfig?.languageServer?.projectDiscoveryMaxDepth ?? 15, + projectDiscoveryExclude: brightscriptConfig?.languageServer?.projectDiscoveryExclude, + logLevel: brightscriptConfig?.languageServer?.logLevel + } }; }) @@ -673,33 +675,42 @@ export class LanguageServer { } /** - * Ask the client for the list of `files.exclude` patterns. Useful when determining if we should process a file + * Ask the client for the list of `files.exclude` and `files.watcherExclude` patterns. Useful when determining if we should process a file */ private async getWorkspaceExcludeGlobs(workspaceFolder: string): Promise { - const filesConfig = await this.getClientConfiguration<{ exclude: string[] }>(workspaceFolder, 'files'); - const fileExcludes = this.extractExcludes(filesConfig); - - const searchConfig = await this.getClientConfiguration<{ exclude: string[] }>(workspaceFolder, 'search'); - const searchExcludes = this.extractExcludes(searchConfig); + const filesConfig = await this.getClientConfiguration<{ exclude: Record; watcherExclude: Record }>(workspaceFolder, 'files'); + const searchConfig = await this.getClientConfiguration<{ exclude: Record }>(workspaceFolder, 'search'); + const languageServerConfig = await this.getClientConfiguration(workspaceFolder, 'brightscript'); - return [...fileExcludes, ...searchExcludes]; + return [ + ...this.extractExcludes(filesConfig?.exclude), + ...this.extractExcludes(filesConfig?.watcherExclude), + ...this.extractExcludes(searchConfig?.exclude), + ...this.extractExcludes(languageServerConfig?.languageServer?.projectDiscoveryExclude) + ]; } - private extractExcludes(config: { exclude: string[] }): string[] { - if (!config?.exclude) { + private extractExcludes(exclude: Record): string[] { + //if the exclude is not defined, return an empty array + if (!exclude) { return []; } return Object - .keys(config.exclude) - .filter(x => config.exclude[x]) + .keys(exclude) + .filter(x => exclude[x]) //vscode files.exclude patterns support ignoring folders without needing to add `**/*`. So for our purposes, we need to //append **/* to everything without a file extension or magic at the end - .map(pattern => [ - //send the pattern as-is (this handles weird cases and exact file matches) - pattern, + .map(pattern => { + const result = [ + //send the pattern as-is (this handles weird cases and exact file matches) + pattern + ]; //treat the pattern as a directory (no harm in doing this because if it's a file, the pattern will just never match anything) - `${pattern}/**/*` - ]) + if (!pattern.endsWith('/**/*')) { + result.push(`${pattern}/**/*`); + } + return result; + }) .flat(1); } @@ -825,6 +836,7 @@ export interface BrightScriptClientConfiguration { languageServer: { enableThreading: boolean; enableProjectDiscovery: boolean; + projectDiscoveryExclude?: Record; logLevel: LogLevel | string; projectDiscoveryMaxDepth?: number; }; diff --git a/src/lsp/ProjectManager.ts b/src/lsp/ProjectManager.ts index 4ec2686c9..fc53a2f1c 100644 --- a/src/lsp/ProjectManager.ts +++ b/src/lsp/ProjectManager.ts @@ -947,6 +947,10 @@ export interface WorkspaceConfig { * 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; /** * The log level to use for this workspace */