diff --git a/.sasjslint b/.sasjslint index 44e07e4..2fef118 100644 --- a/.sasjslint +++ b/.sasjslint @@ -6,5 +6,6 @@ "maxLineLength": 80, "lowerCaseFileNames": true, "noTabIndentation": true, - "indentationMultiple": 2 + "indentationMultiple": 2, + "hasMacroNameInMend": false } \ No newline at end of file diff --git a/src/rules/hasMacroNameInMend.spec.ts b/src/rules/hasMacroNameInMend.spec.ts new file mode 100644 index 0000000..6ea35ca --- /dev/null +++ b/src/rules/hasMacroNameInMend.spec.ts @@ -0,0 +1,266 @@ +import { Severity } from '../types/Severity' +import { hasMacroNameInMend } from './hasMacroNameInMend' + +describe('hasMacroNameInMend', () => { + it('should return an empty array when %mend has correct macro name', () => { + const content = ` + %macro somemacro(); + %put &sysmacroname; + %mend somemacro;` + + expect(hasMacroNameInMend.test(content)).toEqual([]) + }) + + it('should return an empty array when %mend has correct macro name without parentheses', () => { + const content = ` + %macro somemacro; + %put &sysmacroname; + %mend somemacro;` + + expect(hasMacroNameInMend.test(content)).toEqual([]) + }) + + it('should return an array with a single diagnostic when %mend has no macro name', () => { + const content = ` + %macro somemacro; + %put &sysmacroname; + %mend;` + + expect(hasMacroNameInMend.test(content)).toEqual([ + { + message: '%mend missing macro name', + lineNumber: 4, + startColumnNumber: 3, + endColumnNumber: 9, + severity: Severity.Warning + } + ]) + }) + + it('should return an array with a single diagnostic when %mend has incorrect macro name', () => { + const content = ` + %macro somemacro; + %put &sysmacroname; + %mend someanothermacro;` + + expect(hasMacroNameInMend.test(content)).toEqual([ + { + message: 'mismatch macro name in %mend statement', + lineNumber: 4, + startColumnNumber: 9, + endColumnNumber: 25, + severity: Severity.Warning + } + ]) + }) + + it('should return an empty array when the file is undefined', () => { + const content = undefined + + expect(hasMacroNameInMend.test((content as unknown) as string)).toEqual([]) + }) + + describe('nestedMacros', () => { + it('should return an empty array when %mend has correct macro name', () => { + const content = ` + %macro outer(); + + %macro inner(); + %put inner; + %mend inner; + %inner() + %put outer; + %mend outer;` + + expect(hasMacroNameInMend.test(content)).toEqual([]) + }) + + it('should return an array with a single diagnostic when %mend has no macro name(inner)', () => { + const content = ` + %macro outer(); + + %macro inner(); + %put inner; + %mend; + %inner() + %put outer; + %mend outer;` + + expect(hasMacroNameInMend.test(content)).toEqual([ + { + message: '%mend missing macro name', + lineNumber: 6, + startColumnNumber: 5, + endColumnNumber: 11, + severity: Severity.Warning + } + ]) + }) + + it('should return an array with a single diagnostic when %mend has no macro name(outer)', () => { + const content = ` + %macro outer(); + + %macro inner(); + %put inner; + %mend inner; + %inner() + %put outer; + %mend;` + + expect(hasMacroNameInMend.test(content)).toEqual([ + { + message: '%mend missing macro name', + lineNumber: 9, + startColumnNumber: 3, + endColumnNumber: 9, + severity: Severity.Warning + } + ]) + }) + + it('should return an array with two diagnostics when %mend has no macro name(none)', () => { + const content = ` + %macro outer(); + + %macro inner(); + %put inner; + %mend; + %inner() + %put outer; + %mend;` + + expect(hasMacroNameInMend.test(content)).toEqual([ + { + message: '%mend missing macro name', + lineNumber: 6, + startColumnNumber: 5, + endColumnNumber: 11, + severity: Severity.Warning + }, + { + message: '%mend missing macro name', + lineNumber: 9, + startColumnNumber: 3, + endColumnNumber: 9, + severity: Severity.Warning + } + ]) + }) + }) + + describe('with extra spaces and comments', () => { + it('should return an empty array when %mend has correct macro name', () => { + const content = ` + /* 1st comment */ + %macro somemacro ; + + %put &sysmacroname; + + /* 2nd + comment */ + /* 3rd comment */ %mend somemacro ;` + + expect(hasMacroNameInMend.test(content)).toEqual([]) + }) + + it('should return an array with a single diagnostic when %mend has correct macro name having code in comments', () => { + const content = `/** + @file examplemacro.sas + @brief an example of a macro to be used in a service + @details This macro is great. Yadda yadda yadda. Usage: + + * code formatting applies when indented by 4 spaces; code formatting applies when indented by 4 spaces; code formatting applies when indented by 4 spaces; code formatting applies when indented by 4 spaces; code formatting applies when indented by 4 spaces; + + some code + %macro examplemacro123(); + + %examplemacro() + +

SAS Macros

+ @li doesnothing.sas + + @author Allan Bowe + **/ + + %macro examplemacro(); + + proc sql; + create table areas + as select area + + from sashelp.springs; + + %doesnothing(); + + %mend;` + + expect(hasMacroNameInMend.test(content)).toEqual([ + { + message: '%mend missing macro name', + lineNumber: 29, + startColumnNumber: 5, + endColumnNumber: 11, + severity: Severity.Warning + } + ]) + }) + + it('should return an array with a single diagnostic when %mend has incorrect macro name', () => { + const content = ` + %macro somemacro; +/* some comments */ + %put &sysmacroname; +/* some comments */ + %mend someanothermacro ;` + + expect(hasMacroNameInMend.test(content)).toEqual([ + { + message: 'mismatch macro name in %mend statement', + lineNumber: 6, + startColumnNumber: 14, + endColumnNumber: 30, + severity: Severity.Warning + } + ]) + }) + + it('should return an array with a single diagnostic when %mend has no macro name', () => { + const content = ` + %macro somemacro ; + /* some comments */%put &sysmacroname; + %mend ;` + + expect(hasMacroNameInMend.test(content)).toEqual([ + { + message: '%mend missing macro name', + lineNumber: 4, + startColumnNumber: 5, + endColumnNumber: 11, + severity: Severity.Warning + } + ]) + }) + + describe('nestedMacros', () => { + it('should return an empty array when %mend has correct macro name', () => { + const content = ` + %macro outer( ) ; + + + %macro inner(); + + %put inner; + + %mend inner; + + %inner() + + %put outer; + %mend outer;` + + expect(hasMacroNameInMend.test(content)).toEqual([]) + }) + }) + }) +}) diff --git a/src/rules/hasMacroNameInMend.ts b/src/rules/hasMacroNameInMend.ts new file mode 100644 index 0000000..338bf5a --- /dev/null +++ b/src/rules/hasMacroNameInMend.ts @@ -0,0 +1,106 @@ +import { Diagnostic } from '../types/Diagnostic' +import { FileLintRule } from '../types/LintRule' +import { LintRuleType } from '../types/LintRuleType' +import { Severity } from '../types/Severity' + +const name = 'hasMacroNameInMend' +const description = 'The %mend statement should contain the macro name' +const message = '$mend statement missing or incorrect' +const test = (value: string) => { + const diagnostics: Diagnostic[] = [] + + const statements: string[] = value ? value.split(';') : [] + + const stack: string[] = [] + let trimmedStatement = '', + commentStarted = false + statements.forEach((statement, index) => { + ;({ statement: trimmedStatement, commentStarted } = trimComments( + statement, + commentStarted + )) + + if (trimmedStatement.startsWith('%macro ')) { + const macroName = trimmedStatement + .split(' ') + .filter((s: string) => !!s)[1] + .split('(')[0] + stack.push(macroName) + } else if (trimmedStatement.startsWith('%mend')) { + const macroStarted = stack.pop() + const macroName = trimmedStatement + .split(' ') + .filter((s: string) => !!s)[1] + + if (!macroName) { + diagnostics.push({ + message: '%mend missing macro name', + lineNumber: getLineNumber(statements, index + 1), + startColumnNumber: getColNumber(statement, '%mend'), + endColumnNumber: getColNumber(statement, '%mend') + 6, + severity: Severity.Warning + }) + } else if (macroName !== macroStarted) { + diagnostics.push({ + message: 'mismatch macro name in %mend statement', + lineNumber: getLineNumber(statements, index + 1), + startColumnNumber: getColNumber(statement, macroName), + endColumnNumber: + getColNumber(statement, macroName) + macroName.length, + severity: Severity.Warning + }) + } + } + }) + if (stack.length) { + diagnostics.push({ + message: 'missing %mend statement for macro(s)', + lineNumber: statements.length + 1, + startColumnNumber: 1, + endColumnNumber: 1, + severity: Severity.Warning + }) + } + return diagnostics +} + +const trimComments = ( + statement: string, + commentStarted: boolean = false +): { statement: string; commentStarted: boolean } => { + let trimmed = statement.trim() + + if (commentStarted || trimmed.startsWith('/*')) { + const parts = trimmed.split('*/') + if (parts.length > 1) { + return { + statement: (parts.pop() as string).trim(), + commentStarted: false + } + } else { + return { statement: '', commentStarted: true } + } + } + return { statement: trimmed, commentStarted: false } +} + +const getLineNumber = (statements: string[], index: number): number => { + const combinedCode = statements.slice(0, index).join(';') + const lines = (combinedCode.match(/\n/g) || []).length + 1 + return lines +} + +const getColNumber = (statement: string, text: string): number => { + return (statement.split('\n').pop() as string).indexOf(text) + 1 +} + +/** + * Lint rule that checks for the presence of macro name in %mend statement. + */ +export const hasMacroNameInMend: FileLintRule = { + type: LintRuleType.File, + name, + description, + message, + test +} diff --git a/src/types/LintConfig.spec.ts b/src/types/LintConfig.spec.ts index e11cd83..bc88fc3 100644 --- a/src/types/LintConfig.spec.ts +++ b/src/types/LintConfig.spec.ts @@ -40,6 +40,24 @@ describe('LintConfig', () => { expect(config.fileLintRules[0].type).toEqual(LintRuleType.File) }) + it('should create an instance with the hasMacroNameInMend flag set', () => { + const config = new LintConfig({ hasMacroNameInMend: true }) + + expect(config).toBeTruthy() + expect(config.lineLintRules.length).toEqual(0) + expect(config.fileLintRules.length).toEqual(1) + expect(config.fileLintRules[0].name).toEqual('hasMacroNameInMend') + expect(config.fileLintRules[0].type).toEqual(LintRuleType.File) + }) + + it('should create an instance with the hasMacroNameInMend flag off', () => { + const config = new LintConfig({ hasMacroNameInMend: false }) + + expect(config).toBeTruthy() + expect(config.lineLintRules.length).toEqual(0) + expect(config.fileLintRules.length).toEqual(0) + }) + it('should create an instance with the indentation multiple set', () => { const config = new LintConfig({ indentationMultiple: 5 }) @@ -58,18 +76,38 @@ describe('LintConfig', () => { const config = new LintConfig({ noTrailingSpaces: true, noEncodedPasswords: true, - hasDoxygenHeader: true + hasDoxygenHeader: true, + noSpacesInFileNames: true, + lowerCaseFileNames: true, + maxLineLength: 80, + noTabIndentation: true, + indentationMultiple: 2, + hasMacroNameInMend: true }) expect(config).toBeTruthy() - expect(config.lineLintRules.length).toEqual(2) + expect(config.lineLintRules.length).toEqual(5) expect(config.lineLintRules[0].name).toEqual('noTrailingSpaces') expect(config.lineLintRules[0].type).toEqual(LintRuleType.Line) expect(config.lineLintRules[1].name).toEqual('noEncodedPasswords') expect(config.lineLintRules[1].type).toEqual(LintRuleType.Line) + expect(config.lineLintRules[2].name).toEqual('noTabs') + expect(config.lineLintRules[2].type).toEqual(LintRuleType.Line) + expect(config.lineLintRules[3].name).toEqual('maxLineLength') + expect(config.lineLintRules[3].type).toEqual(LintRuleType.Line) + expect(config.lineLintRules[4].name).toEqual('indentationMultiple') + expect(config.lineLintRules[4].type).toEqual(LintRuleType.Line) - expect(config.fileLintRules.length).toEqual(1) + expect(config.fileLintRules.length).toEqual(2) expect(config.fileLintRules[0].name).toEqual('hasDoxygenHeader') expect(config.fileLintRules[0].type).toEqual(LintRuleType.File) + expect(config.fileLintRules[1].name).toEqual('hasMacroNameInMend') + expect(config.fileLintRules[1].type).toEqual(LintRuleType.File) + + expect(config.pathLintRules.length).toEqual(2) + expect(config.pathLintRules[0].name).toEqual('noSpacesInFileNames') + expect(config.pathLintRules[0].type).toEqual(LintRuleType.Path) + expect(config.pathLintRules[1].name).toEqual('lowerCaseFileNames') + expect(config.pathLintRules[1].type).toEqual(LintRuleType.Path) }) }) diff --git a/src/types/LintConfig.ts b/src/types/LintConfig.ts index 184202e..f8e57c2 100644 --- a/src/types/LintConfig.ts +++ b/src/types/LintConfig.ts @@ -6,6 +6,7 @@ import { noEncodedPasswords } from '../rules/noEncodedPasswords' import { noSpacesInFileNames } from '../rules/noSpacesInFileNames' import { noTabIndentation } from '../rules/noTabIndentation' import { noTrailingSpaces } from '../rules/noTrailingSpaces' +import { hasMacroNameInMend } from '../rules/hasMacroNameInMend' import { FileLintRule, LineLintRule, PathLintRule } from './LintRule' /** @@ -56,5 +57,9 @@ export class LintConfig { if (json?.lowerCaseFileNames) { this.pathLintRules.push(lowerCaseFileNames) } + + if (json?.hasMacroNameInMend) { + this.fileLintRules.push(hasMacroNameInMend) + } } } diff --git a/src/utils/getLintConfig.ts b/src/utils/getLintConfig.ts index 032fbf0..9cd359d 100644 --- a/src/utils/getLintConfig.ts +++ b/src/utils/getLintConfig.ts @@ -14,7 +14,8 @@ export const DefaultLintConfiguration = { lowerCaseFileNames: true, maxLineLength: 80, noTabIndentation: true, - indentationMultiple: 2 + indentationMultiple: 2, + hasMacroNameInMend: false } /**