diff --git a/README.md b/README.md index 3498610..adf9d47 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,21 @@ This will highlight lines with trailing spaces. Trailing spaces serve no useful - Default: true - severity: WARNING +### hasRequiredMacroOptions + +This will require macros to have the options listed as "requiredMacroOptions." This is helpful if you want to ensure all macros are SECURE. + +- Default: false +- severity: WARNING + +Example +```json +{ + "hasRequiredMacroOptions": true, + "requiredMacroOptions": ["SECURE", "SRC"] +} +``` + ## severityLevel This setting allows the default severity to be adjusted. This is helpful when running the lint in a pipeline or git hook. Simply list the rules you would like to adjust along with the desired setting ("warn" or "error"), eg as follows: diff --git a/sasjslint-schema.json b/sasjslint-schema.json index 1deed47..954ea18 100644 --- a/sasjslint-schema.json +++ b/sasjslint-schema.json @@ -22,7 +22,9 @@ "noTrailingSpaces": true, "lineEndings": "off", "strictMacroDefinition": true, - "ignoreList": ["sajsbuild", "sasjsresults"] + "ignoreList": ["sajsbuild", "sasjsresults"], + "hasRequiredMacroOptions": false, + "requiredMacroOptions": [] }, "examples": [ { @@ -43,7 +45,9 @@ "hasMacroParentheses": true, "lineEndings": "crlf", "strictMacroDefinition": true, - "ignoreList": ["sajsbuild", "sasjsresults"] + "ignoreList": ["sajsbuild", "sasjsresults"], + "hasRequiredMacroOptions": false, + "requiredMacroOptions": [] } ], "properties": { @@ -204,6 +208,22 @@ "default": ["sasjsbuild/", "sasjsresults/"], "examples": ["sasjs/tests", "tmp/scratch.sas"] }, + "hasRequiredMacroOptions": { + "$id": "#/properties/hasRequiredMacroOptions", + "type": "boolean", + "title": "hasRequiredMacroOptions", + "description": "Enforces required macro options as defined by requiredMacroOptions", + "default": false, + "examples": [true, false] + }, + "requiredMacroOptions": { + "$id": "#/properties/requiredMacroOptions", + "type": "array", + "title": "requiredMacroOptions", + "description": "An array of macro options to require all macros to include.", + "default": [], + "examples": ["['SECURE']", "['SRC', 'STMT']"] + }, "severityLevel": { "$id": "#/properties/severityLevel", "type": "object", @@ -320,6 +340,13 @@ "type": "string", "enum": ["error", "warn"], "default": "warn" + }, + "hasRequiredMacroOptions": { + "$id": "#/properties/severityLevel/hasRequiredMacroOptions", + "title": "hasRequiredMacroOptions", + "type": "string", + "enum": ["error", "warn"], + "default": "warn" } } } diff --git a/src/rules/file/hasRequiredMacroOptions.spec.ts b/src/rules/file/hasRequiredMacroOptions.spec.ts new file mode 100644 index 0000000..3b13b64 --- /dev/null +++ b/src/rules/file/hasRequiredMacroOptions.spec.ts @@ -0,0 +1,123 @@ +import { LintConfig, Severity } from '../../types' +import { hasRequiredMacroOptions } from './hasRequiredMacroOptions' + +describe('hasRequiredMacroOptions - test', () => { + it('should return an empty array when the content has the required macro option(s)', () => { + const contentSecure = '%macro somemacro/ SECURE;' + const configSecure = new LintConfig({ + hasRequiredMacroOptions: true, + requiredMacroOptions: ['SECURE'] + }) + expect(hasRequiredMacroOptions.test(contentSecure, configSecure)).toEqual( + [] + ) + + const contentSecureSrc = '%macro somemacro/ SECURE SRC;' + const configSecureSrc = new LintConfig({ + hasRequiredMacroOptions: true, + requiredMacroOptions: ['SECURE', 'SRC'] + }) + expect( + hasRequiredMacroOptions.test(contentSecureSrc, configSecureSrc) + ).toEqual([]) + + const configEmpty = new LintConfig({ + hasRequiredMacroOptions: true, + requiredMacroOptions: [''] + }) + expect(hasRequiredMacroOptions.test(contentSecureSrc, configEmpty)).toEqual( + [] + ) + }) + + it('should return an array with a single diagnostic when Macro does not contain the required option', () => { + const configSecure = new LintConfig({ + hasRequiredMacroOptions: true, + requiredMacroOptions: ['SECURE'] + }) + + const contentMinXOperator = '%macro somemacro(var1, var2)/minXoperator;' + expect( + hasRequiredMacroOptions.test(contentMinXOperator, configSecure) + ).toEqual([ + { + message: `Macro 'somemacro' does not contain the required option 'SECURE'`, + lineNumber: 1, + startColumnNumber: 0, + endColumnNumber: 0, + severity: Severity.Warning + } + ]) + + const contentSecureSplit = '%macro somemacro(var1, var2)/ SE CURE;' + expect( + hasRequiredMacroOptions.test(contentSecureSplit, configSecure) + ).toEqual([ + { + message: `Macro 'somemacro' does not contain the required option 'SECURE'`, + lineNumber: 1, + startColumnNumber: 0, + endColumnNumber: 0, + severity: Severity.Warning + } + ]) + + const contentNoOption = '%macro somemacro(var1, var2);' + expect(hasRequiredMacroOptions.test(contentNoOption, configSecure)).toEqual( + [ + { + message: `Macro 'somemacro' does not contain the required option 'SECURE'`, + lineNumber: 1, + startColumnNumber: 0, + endColumnNumber: 0, + severity: Severity.Warning + } + ] + ) + }) + + it('should return an array with a two diagnostics when Macro does not contain the required options', () => { + const configSrcStmt = new LintConfig({ + hasRequiredMacroOptions: true, + requiredMacroOptions: ['SRC', 'STMT'], + severityLevel: { hasRequiredMacroOptions: 'warn' } + }) + const contentMinXOperator = '%macro somemacro(var1, var2)/minXoperator;' + expect( + hasRequiredMacroOptions.test(contentMinXOperator, configSrcStmt) + ).toEqual([ + { + message: `Macro 'somemacro' does not contain the required option 'SRC'`, + lineNumber: 1, + startColumnNumber: 0, + endColumnNumber: 0, + severity: Severity.Warning + }, + { + message: `Macro 'somemacro' does not contain the required option 'STMT'`, + lineNumber: 1, + startColumnNumber: 0, + endColumnNumber: 0, + severity: Severity.Warning + } + ]) + }) + + it('should return an array with a one diagnostic when Macro contains 1 of 2 required options', () => { + const configSrcStmt = new LintConfig({ + hasRequiredMacroOptions: true, + requiredMacroOptions: ['SRC', 'STMT'], + severityLevel: { hasRequiredMacroOptions: 'error' } + }) + const contentSrc = '%macro somemacro(var1, var2)/ SRC;' + expect(hasRequiredMacroOptions.test(contentSrc, configSrcStmt)).toEqual([ + { + message: `Macro 'somemacro' does not contain the required option 'STMT'`, + lineNumber: 1, + startColumnNumber: 0, + endColumnNumber: 0, + severity: Severity.Error + } + ]) + }) +}) diff --git a/src/rules/file/hasRequiredMacroOptions.ts b/src/rules/file/hasRequiredMacroOptions.ts new file mode 100644 index 0000000..616c368 --- /dev/null +++ b/src/rules/file/hasRequiredMacroOptions.ts @@ -0,0 +1,52 @@ +import { Diagnostic, LintConfig, Macro, Severity } from '../../types' +import { FileLintRule } from '../../types/LintRule' +import { LintRuleType } from '../../types/LintRuleType' +import { parseMacros } from '../../utils/parseMacros' + +const name = 'hasRequiredMacroOptions' +const description = 'Enforce required macro options' +const message = 'Macro defined without required options' + +const processOptions = ( + macro: Macro, + diagnostics: Diagnostic[], + config?: LintConfig +): void => { + const optionsPresent = macro.declaration.split('/')?.[1]?.trim() ?? '' + const severity = config?.severityLevel[name] || Severity.Warning + + config?.requiredMacroOptions.forEach((option) => { + if (!optionsPresent.includes(option)) { + diagnostics.push({ + message: `Macro '${macro.name}' does not contain the required option '${option}'`, + lineNumber: macro.startLineNumbers[0], + startColumnNumber: 0, + endColumnNumber: 0, + severity + }) + } + }) +} + +const test = (value: string, config?: LintConfig) => { + const diagnostics: Diagnostic[] = [] + + const macros = parseMacros(value, config) + + macros.forEach((macro) => { + processOptions(macro, diagnostics, config) + }) + + return diagnostics +} + +/** + * Lint rule that checks if a macro has the required options + */ +export const hasRequiredMacroOptions: FileLintRule = { + type: LintRuleType.File, + name, + description, + message, + test +} diff --git a/src/rules/file/index.ts b/src/rules/file/index.ts index b3a13b7..cbe3a00 100644 --- a/src/rules/file/index.ts +++ b/src/rules/file/index.ts @@ -4,3 +4,4 @@ export { hasMacroParentheses } from './hasMacroParentheses' export { lineEndings } from './lineEndings' export { noNestedMacros } from './noNestedMacros' export { strictMacroDefinition } from './strictMacroDefinition' +export { hasRequiredMacroOptions } from './hasRequiredMacroOptions' diff --git a/src/types/LintConfig.spec.ts b/src/types/LintConfig.spec.ts index c7abef8..93e5ba3 100644 --- a/src/types/LintConfig.spec.ts +++ b/src/types/LintConfig.spec.ts @@ -1,3 +1,4 @@ +import { hasRequiredMacroOptions } from '../rules/file' import { LineEndings } from './LineEndings' import { LintConfig } from './LintConfig' import { LintRuleType } from './LintRuleType' @@ -168,6 +169,7 @@ describe('LintConfig', () => { hasMacroNameInMend: true, noNestedMacros: true, hasMacroParentheses: true, + hasRequiredMacroOptions: true, noGremlins: true, lineEndings: 'lf' }) @@ -187,7 +189,7 @@ describe('LintConfig', () => { expect(config.lineLintRules[5].name).toEqual('noGremlins') expect(config.lineLintRules[5].type).toEqual(LintRuleType.Line) - expect(config.fileLintRules.length).toEqual(6) + expect(config.fileLintRules.length).toEqual(7) expect(config.fileLintRules[0].name).toEqual('lineEndings') expect(config.fileLintRules[0].type).toEqual(LintRuleType.File) expect(config.fileLintRules[1].name).toEqual('hasDoxygenHeader') @@ -200,6 +202,8 @@ describe('LintConfig', () => { expect(config.fileLintRules[4].type).toEqual(LintRuleType.File) expect(config.fileLintRules[5].name).toEqual('strictMacroDefinition') expect(config.fileLintRules[5].type).toEqual(LintRuleType.File) + expect(config.fileLintRules[6].name).toEqual('hasRequiredMacroOptions') + expect(config.fileLintRules[6].type).toEqual(LintRuleType.File) expect(config.pathLintRules.length).toEqual(2) expect(config.pathLintRules[0].name).toEqual('noSpacesInFileNames') @@ -207,4 +211,25 @@ describe('LintConfig', () => { expect(config.pathLintRules[1].name).toEqual('lowerCaseFileNames') expect(config.pathLintRules[1].type).toEqual(LintRuleType.Path) }) + + it('should throw an error with an invalid value for requiredMacroOptions', () => { + expect( + () => + new LintConfig({ + hasRequiredMacroOptions: true, + requiredMacroOptions: 'test' + }) + ).toThrowError( + `Property "requiredMacroOptions" can only be an array of strings.` + ) + expect( + () => + new LintConfig({ + hasRequiredMacroOptions: true, + requiredMacroOptions: ['test', 2] + }) + ).toThrowError( + `Property "requiredMacroOptions" has invalid type of values. It can only contain strings.` + ) + }) }) diff --git a/src/types/LintConfig.ts b/src/types/LintConfig.ts index e77f7c5..dce2c04 100644 --- a/src/types/LintConfig.ts +++ b/src/types/LintConfig.ts @@ -4,7 +4,8 @@ import { noNestedMacros, hasMacroParentheses, lineEndings, - strictMacroDefinition + strictMacroDefinition, + hasRequiredMacroOptions } from '../rules/file' import { indentationMultiple, @@ -40,6 +41,7 @@ export class LintConfig { readonly lineEndings: LineEndings = LineEndings.LF readonly defaultHeader: string = getDefaultHeader() readonly severityLevel: { [key: string]: Severity } = {} + readonly requiredMacroOptions: string[] = [] constructor(json?: any) { if (json?.ignoreList) { @@ -132,6 +134,31 @@ export class LintConfig { this.fileLintRules.push(strictMacroDefinition) } + if (json?.hasRequiredMacroOptions) { + this.fileLintRules.push(hasRequiredMacroOptions) + + if (json?.requiredMacroOptions) { + if ( + Array.isArray(json.requiredMacroOptions) && + json.requiredMacroOptions.length > 0 + ) { + json.requiredMacroOptions.forEach((item: any) => { + if (typeof item === 'string') { + this.requiredMacroOptions.push(item) + } else { + throw new Error( + `Property "requiredMacroOptions" has invalid type of values. It can only contain strings.` + ) + } + }) + } else { + throw new Error( + `Property "requiredMacroOptions" can only be an array of strings.` + ) + } + } + } + if (json?.noGremlins !== false) { this.lineLintRules.push(noGremlins)