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
}
/**