diff --git a/bsconfig.schema.json b/bsconfig.schema.json index bdd05d56b..e143a8bd6 100644 --- a/bsconfig.schema.json +++ b/bsconfig.schema.json @@ -304,6 +304,35 @@ "type": "string", "description": "A file path relative to rootDir, an absolute path, or a file glob pointing to file(s) you wish to filter diagnostics from." }, + "files": { + "description": "A file glob or list of file globs/src/dest descriptors pointing to file(s) you wish to filter diagnostics from.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "src": { + "type": "string" + }, + "dest": { + "type": "string" + } + } + } + ] + } + } + ] + }, "codes": { "type": "array", "description": "A list of codes of diagnostics that should be filtered out from the files matched in 'src'`. If omitted, all error codes are used", @@ -318,6 +347,45 @@ } ] } + }, + "severities": { + "type": "array", + "description": "A list of diagnostic severities that should be filtered out (error|warn|info|hint)", + "items": { + "type": "string", + "enum": [ + "error", + "warn", + "info", + "hint" + ] + } + }, + "severity": { + "description": "A diagnostic severity or list of severities that should be filtered out (error|warn|info|hint)", + "anyOf": [ + { + "type": "string", + "enum": [ + "error", + "warn", + "info", + "hint" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "error", + "warn", + "info", + "hint" + ] + } + } + ] } } } diff --git a/docs/bsconfig.md b/docs/bsconfig.md index f8cfbe8e2..c69c1692e 100644 --- a/docs/bsconfig.md +++ b/docs/bsconfig.md @@ -123,7 +123,7 @@ If present, overrides the current working directory when invoking `bsc`. Default ## `diagnosticFilters` -Type: `Array; codes?: Array}` +Type: `Array; codes?: Array; severities?: Array<'error' | 'warn' | 'info' | 'hint'>; severity?: 'error' | 'warn' | 'info' | 'hint' | Array<'error' | 'warn' | 'info' | 'hint'>}>` A list of filters used to hide diagnostics. @@ -137,7 +137,7 @@ A list of filters used to hide diagnostics. ] ``` -- An object can also be provided to filter specific diagnostic codes for a file pattern. If no `files` property is included, any diagnostic that matches the values in the `codes` will b e suppressed. If a `string` if given for the `files` property, it is treated as a relative-to-root-dir or absolute file path or glob pattern of the files that should be excluded. These file paths refer to the location of the source files pre-compilation and are relative to [`rootDir`](#rootdir). Absolute file paths may be used as well. For example, +- An object can also be provided to filter specific diagnostic codes and/or severities for a file pattern. If no `files` property is included, any diagnostic that matches the values in `codes` and/or `severities` will be suppressed globally. If a `string` is given for the `files` property, it is treated as a relative-to-root-dir or absolute file path or glob pattern of the files that should be excluded. These file paths refer to the location of the source files pre-compilation and are relative to [`rootDir`](#rootdir). Absolute file paths may be used as well. For example, ```jsonc "diagnosticFilters": [{ @@ -154,11 +154,40 @@ A list of filters used to hide diagnostics. "vendor/**/*", // all vendor files will be suppressed { "src": "themes/theme1/**/*"}, // all files coming from `themes/theme1/` will be suppressed { "dest": "source/common/**/*"}, // all files from `source/common/` will be suppressed - ] + ], "codes": ["cannot-find-name", "mismatch-argument-count"] //ignore these specific codes }] ``` +- An object with `severities` (or `severity`) can be used to suppress diagnostics matching specific severity levels (`"error"`, `"warn"`, `"info"`, `"hint"`), either globally (when `files` is omitted) or for specific `files` patterns: + + ```jsonc + "diagnosticFilters": [ + // ignore all info and hint diagnostics across the entire project + { + "severities": ["info", "hint"] + }, + // ignore warnings, info, and hint diagnostics in vendor files (keep errors) + { + "files": "vendor/**/*", + "severities": ["warn", "info", "hint"] + } + ] + ``` + +- When an object specifies both `codes` and `severities`, a diagnostic is suppressed only when it matches **both** a code in `codes` **and** a severity in `severities` (logical AND). To suppress diagnostics that match either condition (logical OR), use separate filter objects: + + ```jsonc + "diagnosticFilters": [ + // suppress cannot-find-name in vendor files only when its severity is warn + { + "files": "vendor/**/*", + "codes": ["cannot-find-name"], + "severities": ["warn"] + } + ] + ``` + - A file glob may be prefixed with `!` to make it a negative pattern which "un-ignores" the files it matches. (See examples below). Defaults to `undefined`. @@ -171,7 +200,7 @@ If a child bsconfig extends from a parent bsconfig, and both bsconfigs specify ` ### Negative patterns in `diagnosticFilters` -A negative pattern can be used to un-ignore some files or codes which were previously ignored. For example, +A negative pattern can be used to un-ignore some files, codes, or severities which were previously ignored. For example, ```jsonc "diagnosticFilters": [ @@ -180,12 +209,13 @@ A negative pattern can be used to un-ignore some files or codes which were previ ] ``` -A specific error code can be unignored in multiple places by using a pattern which matches everything under `rootDir`. For example, +A specific error code or severity can be unignored in multiple places by using a pattern which matches everything under `rootDir`. For example, ```jsonc "diagnosticFilters": [ - { "files": "vendor/**/*" }, //ignore all errors from vendor libraries - { "files": "!*/**/*", "codes": ["name-collision"] } //EXCEPT do show this particular code everywhere + { "files": "vendor/**/*" }, //ignore all diagnostics from vendor libraries + { "files": "!*/**/*", "codes": ["name-collision"] }, //EXCEPT do show this particular code everywhere + { "files": "!vendor/**/*", "severities": ["error"] } //AND still show errors from vendor libraries ] ``` diff --git a/src/BsConfig.ts b/src/BsConfig.ts index 8e004187d..b6133f8ba 100644 --- a/src/BsConfig.ts +++ b/src/BsConfig.ts @@ -1,5 +1,8 @@ +import type { DiagnosticSeverity } from 'vscode-languageserver-protocol'; import type { LogLevel } from './logging'; +export type BsDiagnosticSeverity = 'error' | 'warn' | 'info' | 'hint' | DiagnosticSeverity; + export interface BsConfig { /** * The inheritance tree for all parent configs used to generate this config. Do not set this, it is computed. @@ -118,7 +121,16 @@ export interface BsConfig { /** * A list of filters used to exclude diagnostics from the output */ - diagnosticFilters?: Array; codes?: Array }>; + diagnosticFilters?: Array< + | string + | number + | { + files?: string | Array; + codes?: Array; + severities?: BsDiagnosticSeverity[]; + severity?: BsDiagnosticSeverity | BsDiagnosticSeverity[]; + } + >; /** * Use the deprecated diagnosticFilters format from v0. This is useful for backwards compatibility. diff --git a/src/DiagnosticFilterer.spec.ts b/src/DiagnosticFilterer.spec.ts index 09a90f44a..e4e8f10db 100644 --- a/src/DiagnosticFilterer.spec.ts +++ b/src/DiagnosticFilterer.spec.ts @@ -1,6 +1,7 @@ import { expect } from './chai-config.spec'; import { DiagnosticFilterer } from './DiagnosticFilterer'; import type { BsDiagnostic } from './interfaces'; +import { DiagnosticSeverity } from 'vscode-languageserver-protocol'; import { Program } from './Program'; import util, { standardizePath as s } from './util'; import { createSandbox } from 'sinon'; @@ -794,14 +795,164 @@ describe('DiagnosticFilterer', () => { }); }); + describe('filtering by severity', () => { + it('normalizes global and file-scoped severity filters', () => { + expect(filterer.getDiagnosticFilters({ + diagnosticFilters: [ + { severities: ['warn', 'info'] }, + { severity: 'hint' }, + { files: 'lib/**/*.brs', severities: ['warn', 'info', 'hint'] }, + { files: '!lib/critical/**/*.brs', severity: ['error', 'warn'] }, + { files: 'source/**/*.brs', codes: [1001], severities: ['warn'] } + ] + })).to.eql([ + { severities: ['warn', 'info'], isNegative: false }, + { severities: ['hint'], isNegative: false }, + { src: 'lib/**/*.brs', severities: ['warn', 'info', 'hint'], isNegative: false }, + { src: 'lib/critical/**/*.brs', severities: ['error', 'warn'], isNegative: true }, + { src: 'source/**/*.brs', codes: [1001], severities: ['warn'], isNegative: false } + ]); + }); + + it('normalizes severity filters in v0 compatibility mode', () => { + expect(filterer.getDiagnosticFilters({ + diagnosticFiltersV0Compatibility: true, + diagnosticFilters: [ + { severities: ['info', 'hint'] }, + { src: 'lib/**/*.brs', severities: ['warn'] }, + { src: '!lib/critical/**/*.brs', codes: [100], severities: ['error'] } + ] as any + })).to.eql([ + { severities: ['info', 'hint'], isNegative: false }, + { src: 'lib/**/*.brs', severities: ['warn'], isNegative: false }, + { src: 'lib/critical/**/*.brs', codes: [100], severities: ['error'], isNegative: true } + ]); + }); + + it('filters diagnostics globally by severity string and DiagnosticSeverity enum', () => { + const diagnostics = [ + getDiagnostic(1, `${rootDir}/source/main.brs`, undefined, DiagnosticSeverity.Error), + getDiagnostic(2, `${rootDir}/source/main.brs`, undefined, DiagnosticSeverity.Warning), + getDiagnostic(3, `${rootDir}/source/main.brs`, undefined, DiagnosticSeverity.Information), + getDiagnostic(4, `${rootDir}/source/main.brs`, undefined, DiagnosticSeverity.Hint) + ]; + + expect( + filterer.filter({ + rootDir: rootDir, + diagnosticFilters: [ + { severities: ['warn', 'info', DiagnosticSeverity.Hint] } + ] + }, diagnostics).map(x => x.code) + ).to.eql([1]); + }); + + it('filters diagnostics by files glob and severities', () => { + const diagnostics = [ + getDiagnostic(1, `${rootDir}/lib/vendor.brs`, undefined, DiagnosticSeverity.Error), + getDiagnostic(2, `${rootDir}/lib/vendor.brs`, undefined, DiagnosticSeverity.Warning), + getDiagnostic(3, `${rootDir}/lib/vendor.brs`, undefined, DiagnosticSeverity.Information), + getDiagnostic(4, `${rootDir}/source/main.brs`, undefined, DiagnosticSeverity.Warning) + ]; + + expect( + filterer.filter({ + rootDir: rootDir, + diagnosticFilters: [ + { files: 'lib/**/*.brs', severities: ['warn', 'info', 'hint'] } + ] + }, diagnostics).map(x => x.code) + ).to.eql([1, 4]); + }); + + it('filters diagnostics by dest path and severities', () => { + const program = new Program({ rootDir: rootDir }); + program.setFile('source/common.brs', ''); + program.setFile({ src: `${rootDir}/source/utils.brs`, dest: 'source/vendor/utils.brs' }, ''); + + const diagnostics = [ + getDiagnostic('err1', `${rootDir}/source/utils.brs`, `${rootDir}/source/vendor/utils.brs`, DiagnosticSeverity.Error), + getDiagnostic('warn1', `${rootDir}/source/utils.brs`, `${rootDir}/source/vendor/utils.brs`, DiagnosticSeverity.Warning), + getDiagnostic('warn2', `${rootDir}/source/common.brs`, undefined, DiagnosticSeverity.Warning) + ]; + + const result = filterer.filter({ + rootDir: rootDir, + diagnosticFilters: [ + { + files: [{ dest: 'source/vendor/**/*.*' }], + severities: ['warn'] + } + ] + }, diagnostics, program); + + expect(result.map(x => x.code)).to.eql(['err1', 'warn2']); + }); + + it('combines codes and severities in the same filter object using logical AND', () => { + const diagnostics = [ + getDiagnostic(100, `${rootDir}/source/main.brs`, undefined, DiagnosticSeverity.Warning), // matches both -> suppress + getDiagnostic(100, `${rootDir}/source/main.brs`, undefined, DiagnosticSeverity.Error), // matches code only -> keep + getDiagnostic(200, `${rootDir}/source/main.brs`, undefined, DiagnosticSeverity.Warning) // matches severity only -> keep + ]; + + expect( + filterer.filter({ + rootDir: rootDir, + diagnosticFilters: [ + { files: 'source/**/*.brs', codes: [100], severities: ['warn'] } + ] + }, diagnostics).map(x => `${x.code}:${x.severity}`) + ).to.eql([ + `100:${DiagnosticSeverity.Error}`, + `200:${DiagnosticSeverity.Warning}` + ]); + }); + + it('supports negative globs with severities to un-ignore specific severities', () => { + const diagnostics = [ + getDiagnostic(1, `${rootDir}/lib/vendor.brs`, undefined, DiagnosticSeverity.Error), + getDiagnostic(2, `${rootDir}/lib/vendor.brs`, undefined, DiagnosticSeverity.Warning), + getDiagnostic(3, `${rootDir}/lib/vendor.brs`, undefined, DiagnosticSeverity.Information) + ]; + + expect( + filterer.filter({ + rootDir: rootDir, + diagnosticFilters: [ + { files: 'lib/**/*.brs' }, + { files: '!lib/**/*.brs', severities: ['error'] } + ] + }, diagnostics).map(x => x.code) + ).to.eql([1]); + }); + + it('does not mark a file as completely filtered when the filter specifies severities', () => { + filterer.options = { + rootDir: rootDir, + diagnosticFilters: [ + { files: 'lib/**/*.brs', severities: ['warn', 'info', 'hint'] } + ] + }; + expect( + filterer.isFileCompletelyFiltered({ + srcPath: `${rootDir}/lib/some/file.brs`, + destPath: 'source/some/file.brs' + }) + ).to.be.false; + }); + }); + }); -function getDiagnostic(code: number | string, srcPath: string, destPath?: string) { +function getDiagnostic(code: number | string, srcPath: string, destPath?: string, severity?: DiagnosticSeverity) { destPath = destPath ?? srcPath; return { location: { uri: util.pathToUri(s`${srcPath}`) }, - code: code + code: code, + ...(severity !== undefined ? { severity: severity } : {}) } as BsDiagnostic; } + diff --git a/src/DiagnosticFilterer.ts b/src/DiagnosticFilterer.ts index 4faa40643..2e57a867c 100644 --- a/src/DiagnosticFilterer.ts +++ b/src/DiagnosticFilterer.ts @@ -1,7 +1,8 @@ import type { BsDiagnostic } from './interfaces'; +import { DiagnosticSeverity } from 'vscode-languageserver-protocol'; import * as path from 'path'; import * as micromatch from 'micromatch'; -import type { BsConfig } from './BsConfig'; +import type { BsConfig, BsDiagnosticSeverity } from './BsConfig'; import util, { standardizePath as s } from './util'; import { URI } from 'vscode-uri'; import type { Program } from './Program'; @@ -15,6 +16,7 @@ interface NormalizedFilter { src?: string; dest?: string; codes?: (number | string)[]; + severities?: BsDiagnosticSeverity[]; isNegative: boolean; } @@ -99,7 +101,7 @@ export class DiagnosticFilterer { let isMatch = false; //filter each matched file for (let filter of this.filters) { - if (filter.codes?.length > 0) { + if (filter.codes?.length > 0 || filter.severities?.length > 0) { continue; } let srcMatch = false; @@ -242,26 +244,91 @@ export class DiagnosticFilterer { //if the filter is not negative we're turning diagnostics off const isSuppressing = !filter.isNegative; const lowerFileUri = fileUri.toLowerCase(); - //if there is no code, set isSuppressed on every diagnostic in this file - if (!filter.codes) { + const hasCodes = Array.isArray(filter.codes); + const hasSeverities = Array.isArray(filter.severities) && filter.severities.length > 0; + //if there is no code or severity filter, set isSuppressed on every diagnostic in this file + if (!hasCodes && !hasSeverities) { this.byFile[lowerFileUri].forEach(diagnostic => { diagnostic.isSuppressed = isSuppressing; }); - //set isSuppressed for any diagnostics with matching codes + //set isSuppressed for any diagnostics with matching codes and/or severities } else { let fileDiagnostics = this.byFile[lowerFileUri]; for (const diagnostic of fileDiagnostics) { - if (filter.codes.includes(diagnostic.diagnostic.code!) || - (diagnostic.diagnostic.legacyCode && - (filter.codes.includes(diagnostic.diagnostic.legacyCode) || - filter.codes.includes(diagnostic.diagnostic.legacyCode.toString())))) { + const codeMatches = hasCodes ? this.matchesCode(filter, diagnostic.diagnostic) : true; + const severityMatches = hasSeverities ? this.matchesSeverity(filter, diagnostic.diagnostic) : true; + if (codeMatches && severityMatches) { diagnostic.isSuppressed = isSuppressing; } } } } + private matchesCode(filter: NormalizedFilter, diagnostic: BsDiagnostic): boolean { + return !!( + filter.codes?.includes(diagnostic.code!) || + (diagnostic.legacyCode && + (filter.codes?.includes(diagnostic.legacyCode) || + filter.codes?.includes(diagnostic.legacyCode.toString()))) + ); + } + + private matchesSeverity(filter: NormalizedFilter, diagnostic: BsDiagnostic): boolean { + const diagnosticSeverity = this.toDiagnosticSeverity(diagnostic.severity); + if (diagnosticSeverity === undefined) { + return false; + } + return !!filter.severities?.some(s => this.toDiagnosticSeverity(s) === diagnosticSeverity); + } + + private toDiagnosticSeverity(value: unknown): DiagnosticSeverity | undefined { + if (typeof value === 'number') { + if ( + value === DiagnosticSeverity.Error || + value === DiagnosticSeverity.Warning || + value === DiagnosticSeverity.Information || + value === DiagnosticSeverity.Hint + ) { + return value; + } + return undefined; + } + if (typeof value === 'string') { + switch (value.toLowerCase()) { + case 'error': + return DiagnosticSeverity.Error; + case 'warn': + case 'warning': + return DiagnosticSeverity.Warning; + case 'info': + case 'information': + return DiagnosticSeverity.Information; + case 'hint': + return DiagnosticSeverity.Hint; + } + } + return undefined; + } + + private extractSeverities(filter: { severities?: unknown; severity?: unknown }): BsDiagnosticSeverity[] | undefined { + let rawSeverities: unknown[] | undefined; + if (Array.isArray(filter.severities)) { + rawSeverities = filter.severities; + } else if (Array.isArray(filter.severity)) { + rawSeverities = filter.severity; + } else if (filter.severity !== undefined) { + rawSeverities = [filter.severity]; + } + if (!rawSeverities) { + return undefined; + } + const validSeverities = rawSeverities.filter( + (s): s is BsDiagnosticSeverity => this.toDiagnosticSeverity(s) !== undefined + ); + return validSeverities.length > 0 ? validSeverities : undefined; + } + public getDiagnosticFilters(config: BsConfig) { if (config.diagnosticFiltersV0Compatibility) { return this.getDiagnosticFiltersV0(config); @@ -295,12 +362,21 @@ export class DiagnosticFilterer { continue; } - //code-only filter - if ('codes' in filter && !('files' in filter) && Array.isArray(filter.codes)) { - result.push({ - codes: filter.codes, + const hasCodes = 'codes' in filter && Array.isArray(filter.codes); + const severities = this.extractSeverities(filter); + + //code/severity-only filter + if (!('files' in filter) && (hasCodes || severities)) { + const normalized: NormalizedFilter = { isNegative: false - }); + }; + if (hasCodes) { + normalized.codes = filter.codes; + } + if (severities) { + normalized.severities = severities; + } + result.push(normalized); continue; } @@ -373,12 +449,21 @@ export class DiagnosticFilterer { continue; } - //code-only filter - if ('codes' in filter && !('src' in filter) && Array.isArray(filter.codes)) { - result.push({ - codes: filter.codes, + const hasCodes = 'codes' in filter && Array.isArray(filter.codes); + const severities = this.extractSeverities(filter as { severities?: unknown; severity?: unknown }); + + //code/severity-only filter + if (!('src' in filter) && (hasCodes || severities)) { + const normalized: NormalizedFilter = { isNegative: false - }); + }; + if (hasCodes) { + normalized.codes = filter.codes; + } + if (severities) { + normalized.severities = severities; + } + result.push(normalized); continue; } @@ -386,56 +471,42 @@ export class DiagnosticFilterer { const isNegative = filter.src.startsWith('!'); const trimmedFilter = isNegative ? filter.src.slice(1) : filter.src; + const normalized: NormalizedFilter = { + src: trimmedFilter, + isNegative: isNegative + }; if ('codes' in filter) { - result.push({ - src: trimmedFilter, - codes: filter.codes, - isNegative: isNegative - }); - } else { - result.push({ - src: trimmedFilter, - isNegative: isNegative - }); + normalized.codes = filter.codes; } + if (severities) { + normalized.severities = severities; + } + result.push(normalized); } } return result; } - private getNormalizedFilter(fileGlob: string, filter: { files: string } | { codes?: (number | string)[] }, locationKey: 'src' | 'dest' = 'src'): NormalizedFilter { + private getNormalizedFilter( + fileGlob: string, + filter: { files?: any; codes?: (number | string)[]; severities?: BsDiagnosticSeverity[]; severity?: BsDiagnosticSeverity | BsDiagnosticSeverity[] }, + locationKey: 'src' | 'dest' = 'src' + ): NormalizedFilter { const isNegative = fileGlob.startsWith('!'); const trimmedFilter = isNegative ? fileGlob.slice(1) : fileGlob; - if (locationKey === 'src') { - if ('codes' in filter && Array.isArray(filter.codes)) { - return { - src: trimmedFilter, - codes: filter.codes, - isNegative: isNegative - }; - } else { - return { - src: trimmedFilter, - isNegative: isNegative - }; - } - } else { - // dest - if ('codes' in filter && Array.isArray(filter.codes)) { - return { - dest: trimmedFilter, - codes: filter.codes, - isNegative: isNegative - }; - } else { - return { - dest: trimmedFilter, - isNegative: isNegative - }; - } + const normalized: NormalizedFilter = { + [locationKey]: trimmedFilter, + isNegative: isNegative + }; + if ('codes' in filter && Array.isArray(filter.codes)) { + normalized.codes = filter.codes; } - + const severities = this.extractSeverities(filter); + if (severities) { + normalized.severities = severities; + } + return normalized; } /** diff --git a/src/DiagnosticManager.ts b/src/DiagnosticManager.ts index c727d5d88..beca63ef5 100644 --- a/src/DiagnosticManager.ts +++ b/src/DiagnosticManager.ts @@ -164,14 +164,14 @@ export class DiagnosticManager { public getDiagnostics(): BsDiagnostic[] { const doDiagnosticsGathering = () => { const diagnostics = this.getNonSuppressedDiagnostics(); + this.logger?.time(LogLevel.debug, ['adjust diagnostics severity'], () => { + this.diagnosticAdjuster?.adjust(this.options ?? {}, diagnostics); + }); + const filteredDiagnostics = this.logger?.time(LogLevel.debug, ['filter diagnostics'], () => { return this.filterDiagnostics(diagnostics); }) ?? this.filterDiagnostics(diagnostics); - this.logger?.time(LogLevel.debug, ['adjust diagnostics severity'], () => { - this.diagnosticAdjuster?.adjust(this.options ?? {}, filteredDiagnostics); - }); - this.logger?.info(`diagnostic counts: total=${chalk.yellow(diagnostics.length.toString())}, after filter=${chalk.yellow(filteredDiagnostics.length.toString())}`); return filteredDiagnostics;