diff --git a/src/format/formatText.spec.ts b/src/format/formatText.spec.ts index 35f2a53..2cccf0d 100644 --- a/src/format/formatText.spec.ts +++ b/src/format/formatText.spec.ts @@ -12,14 +12,14 @@ describe('formatText', () => { new LintConfig(getLintConfigModule.DefaultLintConfiguration) ) ) - const text = `%macro test + const text = `%macro test; %put 'hello';\r\n%mend; ` const expectedOutput = `/** @file @brief

SAS Macros

-**/\n%macro test +**/\n%macro test; %put 'hello';\n%mend test;` const output = await formatText(text) @@ -38,9 +38,9 @@ describe('formatText', () => { }) ) ) - const text = `%macro test\n %put 'hello';\r\n%mend; ` + const text = `%macro test;\n %put 'hello';\r\n%mend; ` - const expectedOutput = `/**\r\n @file\r\n @brief \r\n

SAS Macros

\r\n**/\r\n%macro test\r\n %put 'hello';\r\n%mend test;` + const expectedOutput = `/**\r\n @file\r\n @brief \r\n

SAS Macros

\r\n**/\r\n%macro test;\r\n %put 'hello';\r\n%mend test;` const output = await formatText(text) diff --git a/src/rules/file/hasMacroNameInMend.spec.ts b/src/rules/file/hasMacroNameInMend.spec.ts index 5693a2e..8acf000 100644 --- a/src/rules/file/hasMacroNameInMend.spec.ts +++ b/src/rules/file/hasMacroNameInMend.spec.ts @@ -56,7 +56,7 @@ describe('hasMacroNameInMend - test', () => { it('should return an array with a diagnostic for each macro missing an %mend statement', () => { const content = `%macro somemacro; %put &sysmacroname; - %macro othermacro` + %macro othermacro;` expect(hasMacroNameInMend.test(content)).toEqual([ { diff --git a/src/rules/file/hasMacroNameInMend.ts b/src/rules/file/hasMacroNameInMend.ts index cab7ac1..0a37100 100644 --- a/src/rules/file/hasMacroNameInMend.ts +++ b/src/rules/file/hasMacroNameInMend.ts @@ -17,7 +17,7 @@ const test = (value: string, config?: LintConfig) => { const macros = parseMacros(value, config) const diagnostics: Diagnostic[] = [] macros.forEach((macro) => { - if (macro.startLineNumber === null && macro.endLineNumber !== null) { + if (macro.startLineNumbers.length === 0 && macro.endLineNumber !== null) { const endLine = lines[macro.endLineNumber - 1] diagnostics.push({ message: `%mend statement is redundant`, @@ -27,10 +27,13 @@ const test = (value: string, config?: LintConfig) => { getColumnNumber(endLine, '%mend') + macro.termination.length, severity: Severity.Warning }) - } else if (macro.endLineNumber === null && macro.startLineNumber !== null) { + } else if ( + macro.endLineNumber === null && + macro.startLineNumbers.length !== 0 + ) { diagnostics.push({ message: `Missing %mend statement for macro - ${macro.name}`, - lineNumber: macro.startLineNumber, + lineNumber: macro.startLineNumbers![0], startColumnNumber: 1, endColumnNumber: 1, severity: Severity.Warning @@ -73,7 +76,7 @@ const fix = (value: string, config?: LintConfig): string => { const macros = parseMacros(value, config) macros.forEach((macro) => { - if (macro.startLineNumber === null && macro.endLineNumber !== null) { + if (macro.startLineNumbers.length === 0 && macro.endLineNumber !== null) { // %mend statement is redundant const endLine = lines[macro.endLineNumber - 1] const startColumnNumber = getColumnNumber(endLine, '%mend') @@ -83,7 +86,10 @@ const fix = (value: string, config?: LintConfig): string => { const beforeStatement = endLine.slice(0, startColumnNumber - 1) const afterStatement = endLine.slice(endColumnNumber) lines[macro.endLineNumber - 1] = beforeStatement + afterStatement - } else if (macro.endLineNumber === null && macro.startLineNumber !== null) { + } else if ( + macro.endLineNumber === null && + macro.startLineNumbers.length !== 0 + ) { // missing %mend statement } else if (macro.mismatchedMendMacroName) { // mismatched macro name diff --git a/src/rules/file/hasMacroParentheses.spec.ts b/src/rules/file/hasMacroParentheses.spec.ts index 1fa58bc..3bd7b2c 100644 --- a/src/rules/file/hasMacroParentheses.spec.ts +++ b/src/rules/file/hasMacroParentheses.spec.ts @@ -139,18 +139,4 @@ describe('hasMacroParentheses', () => { ]) }) }) - - it('should return an array with a single diagnostic when a macro definition contains a space', () => { - const content = `%macro test ()` - - expect(hasMacroParentheses.test(content)).toEqual([ - { - message: 'Macro definition contains space(s)', - lineNumber: 1, - startColumnNumber: 8, - endColumnNumber: 14, - severity: Severity.Warning - } - ]) - }) }) diff --git a/src/rules/file/hasMacroParentheses.ts b/src/rules/file/hasMacroParentheses.ts index 7975cba..435d98d 100644 --- a/src/rules/file/hasMacroParentheses.ts +++ b/src/rules/file/hasMacroParentheses.ts @@ -16,36 +16,36 @@ const test = (value: string, config?: LintConfig) => { if (!macro.name) { diagnostics.push({ message: 'Macro definition missing name', - lineNumber: macro.startLineNumber!, - startColumnNumber: getColumnNumber(macro.declarationLine, '%macro'), + lineNumber: macro.startLineNumbers![0], + startColumnNumber: getColumnNumber( + macro.declarationLines![0], + '%macro' + ), endColumnNumber: - getColumnNumber(macro.declarationLine, '%macro') + + getColumnNumber(macro.declarationLines![0], '%macro') + macro.declaration.length, severity: Severity.Warning }) - } else if (!macro.declarationLine.includes('(')) { + } else if (!macro.declarationLines.find((dl) => dl.includes('('))) { + const macroNameLineIndex = macro.declarationLines.findIndex((dl) => + dl.includes(macro.name) + ) diagnostics.push({ message, - lineNumber: macro.startLineNumber!, - startColumnNumber: getColumnNumber(macro.declarationLine, macro.name), + lineNumber: macro.startLineNumbers![macroNameLineIndex], + startColumnNumber: getColumnNumber( + macro.declarationLines[macroNameLineIndex], + macro.name + ), endColumnNumber: - getColumnNumber(macro.declarationLine, macro.name) + + getColumnNumber( + macro.declarationLines[macroNameLineIndex], + macro.name + ) + macro.name.length - 1, severity: Severity.Warning }) - } else if (macro.name !== macro.name.trim()) { - diagnostics.push({ - message: 'Macro definition contains space(s)', - lineNumber: macro.startLineNumber!, - startColumnNumber: getColumnNumber(macro.declarationLine, macro.name), - endColumnNumber: - getColumnNumber(macro.declarationLine, macro.name) + - macro.name.length - - 1 + - `()`.length, - severity: Severity.Warning - }) } }) diff --git a/src/rules/file/index.ts b/src/rules/file/index.ts index 40730af..b3a13b7 100644 --- a/src/rules/file/index.ts +++ b/src/rules/file/index.ts @@ -3,3 +3,4 @@ export { hasMacroNameInMend } from './hasMacroNameInMend' export { hasMacroParentheses } from './hasMacroParentheses' export { lineEndings } from './lineEndings' export { noNestedMacros } from './noNestedMacros' +export { strictMacroDefinition } from './strictMacroDefinition' diff --git a/src/rules/file/noNestedMacros.ts b/src/rules/file/noNestedMacros.ts index 338ae15..b255545 100644 --- a/src/rules/file/noNestedMacros.ts +++ b/src/rules/file/noNestedMacros.ts @@ -22,17 +22,17 @@ const test = (value: string, config?: LintConfig) => { message: message .replace('{macro}', macro.name) .replace('{parent}', macro.parentMacro), - lineNumber: macro.startLineNumber as number, + lineNumber: macro.startLineNumbers![0] as number, startColumnNumber: getColumnNumber( - lines[(macro.startLineNumber as number) - 1], + lines[(macro.startLineNumbers![0] as number) - 1], '%macro' ), endColumnNumber: getColumnNumber( - lines[(macro.startLineNumber as number) - 1], + lines[(macro.startLineNumbers![0] as number) - 1], '%macro' ) + - lines[(macro.startLineNumber as number) - 1].trim().length - + lines[(macro.startLineNumbers![0] as number) - 1].trim().length - 1, severity: Severity.Warning }) diff --git a/src/rules/file/strictMacroDefinition.spec.ts b/src/rules/file/strictMacroDefinition.spec.ts new file mode 100644 index 0000000..0e5b50e --- /dev/null +++ b/src/rules/file/strictMacroDefinition.spec.ts @@ -0,0 +1,216 @@ +import { LintConfig, Severity } from '../../types' +import { strictMacroDefinition } from './strictMacroDefinition' + +describe('strictMacroDefinition', () => { + it('should return an empty array when the content has correct macro definition syntax', () => { + const content = '%macro somemacro;' + expect(strictMacroDefinition.test(content)).toEqual([]) + + const content2 = '%macro somemacro();' + expect(strictMacroDefinition.test(content2)).toEqual([]) + + const content3 = '%macro somemacro(var1);' + expect(strictMacroDefinition.test(content3)).toEqual([]) + + const content4 = '%macro somemacro/minoperator;' + expect(strictMacroDefinition.test(content4)).toEqual([]) + + const content5 = '%macro somemacro /minoperator;' + expect(strictMacroDefinition.test(content5)).toEqual([]) + + const content6 = '%macro somemacro(var1, var2)/minoperator;' + expect(strictMacroDefinition.test(content6)).toEqual([]) + + const content7 = + ' /* Some Comment */ %macro somemacro(var1, var2) /minoperator ; /* Some Comment */' + expect(strictMacroDefinition.test(content7)).toEqual([]) + + const content8 = + '%macro macroName( arr, arr/* / store source */3 ) /* / store source */;/* / store source */' + expect(strictMacroDefinition.test(content8)).toEqual([]) + + const content9 = '%macro macroName(var1, var2=with space, var3=);' + expect(strictMacroDefinition.test(content9)).toEqual([]) + + const content10 = '%macro macroName()/ /* some comment */ store source;' + expect(strictMacroDefinition.test(content10)).toEqual([]) + + const content11 = '`%macro macroName() /* / store source */;' + expect(strictMacroDefinition.test(content11)).toEqual([]) + + const content12 = + '%macro macroName()/ /* some comment */ store des="some description";' + expect(strictMacroDefinition.test(content12)).toEqual([]) + }) + + it('should return an array with a single diagnostic when Macro definition has space in param', () => { + const content = '%macro somemacro(va r1);' + expect(strictMacroDefinition.test(content)).toEqual([ + { + message: `Param 'va r1' cannot have space`, + lineNumber: 1, + startColumnNumber: 18, + endColumnNumber: 22, + severity: Severity.Warning + } + ]) + }) + + it('should return an array with a two diagnostics when Macro definition has space in params', () => { + const content = '%macro somemacro(var1, var 2, v ar3, var4);' + expect(strictMacroDefinition.test(content)).toEqual([ + { + message: `Param 'var 2' cannot have space`, + lineNumber: 1, + startColumnNumber: 24, + endColumnNumber: 28, + severity: Severity.Warning + }, + { + message: `Param 'v ar3' cannot have space`, + lineNumber: 1, + startColumnNumber: 31, + endColumnNumber: 35, + severity: Severity.Warning + } + ]) + }) + + it('should return an array with a two diagnostics when Macro definition has space in params - special case', () => { + const content = + '%macro macroName( arr, ar r/* / store source */ 3 ) /* / store source */;/* / store source */' + expect(strictMacroDefinition.test(content)).toEqual([ + { + message: `Param 'ar r 3' cannot have space`, + lineNumber: 1, + startColumnNumber: 24, + endColumnNumber: 49, + severity: Severity.Warning + } + ]) + }) + + it('should return an array with a single diagnostic when Macro definition has invalid option', () => { + const content = '%macro somemacro(var1, var2)/minXoperator;' + expect(strictMacroDefinition.test(content)).toEqual([ + { + message: `Option 'minXoperator' is not valid`, + lineNumber: 1, + startColumnNumber: 30, + endColumnNumber: 41, + severity: Severity.Warning + } + ]) + }) + + it('should return an array with a two diagnostics when Macro definition has invalid options', () => { + const content = + '%macro somemacro(var1, var2)/ store invalidoption secure ;' + expect(strictMacroDefinition.test(content)).toEqual([ + { + message: `Option 'invalidoption' is not valid`, + lineNumber: 1, + startColumnNumber: 39, + endColumnNumber: 51, + severity: Severity.Warning + } + ]) + }) + + describe('multi-content macro declarations', () => { + it('should return an empty array when the content has correct macro definition syntax', () => { + const content = `%macro mp_ds2cards(base_ds=, tgt_ds=\n ,cards_file="%sysfunc(pathname(work))/cardgen.sas"\n ,maxobs=max\n ,random_sample=NO\n ,showlog=YES\n ,outencoding=\n ,append=NO\n)/*/STORE SOURCE*/;` + expect(strictMacroDefinition.test(content)).toEqual([]) + + const content2 = `%macro mm_createapplication(\n tree=/User Folders/sasdemo\n ,name=myApp\n ,ClassIdentifier=mcore\n ,desc=Created by mm_createapplication\n ,params= param1=1 param2=blah\n ,version=\n ,frefin=mm_in\n ,frefout=mm_out\n ,mDebug=1\n );` + expect(strictMacroDefinition.test(content2)).toEqual([]) + }) + + it('should return an array with a single diagnostic when Macro definition has space in param', () => { + const content = `%macro + somemacro(va r1);` + expect(strictMacroDefinition.test(content)).toEqual([ + { + message: `Param 'va r1' cannot have space`, + lineNumber: 2, + startColumnNumber: 18, + endColumnNumber: 22, + severity: Severity.Warning + } + ]) + }) + + it('should return an array with a two diagnostics when Macro definition has space in params', () => { + const content = `%macro somemacro( + var1, + var 2, + v ar3, + var4);` + expect(strictMacroDefinition.test(content)).toEqual([ + { + message: `Param 'var 2' cannot have space`, + lineNumber: 3, + startColumnNumber: 7, + endColumnNumber: 11, + severity: Severity.Warning + }, + { + message: `Param 'v ar3' cannot have space`, + lineNumber: 4, + startColumnNumber: 7, + endColumnNumber: 11, + severity: Severity.Warning + } + ]) + }) + + it('should return an array with a two diagnostics when Macro definition has space in params - special case', () => { + const content = `%macro macroName( + arr, + ar r/* / store source */ 3 + ) /* / store source */;/* / store source */` + expect(strictMacroDefinition.test(content)).toEqual([ + { + message: `Param 'ar r 3' cannot have space`, + lineNumber: 3, + startColumnNumber: 7, + endColumnNumber: 32, + severity: Severity.Warning + } + ]) + }) + + it('should return an array with a single diagnostic when Macro definition has invalid option', () => { + const content = `%macro somemacro(var1, var2) + /minXoperator;` + expect(strictMacroDefinition.test(content)).toEqual([ + { + message: `Option 'minXoperator' is not valid`, + lineNumber: 2, + startColumnNumber: 8, + endColumnNumber: 19, + severity: Severity.Warning + } + ]) + }) + + it('should return an array with a two diagnostics when Macro definition has invalid options', () => { + const content = `%macro + somemacro( + var1, var2 + ) + / store + invalidoption + secure ;` + expect(strictMacroDefinition.test(content)).toEqual([ + { + message: `Option 'invalidoption' is not valid`, + lineNumber: 6, + startColumnNumber: 16, + endColumnNumber: 28, + severity: Severity.Warning + } + ]) + }) + }) +}) diff --git a/src/rules/file/strictMacroDefinition.ts b/src/rules/file/strictMacroDefinition.ts new file mode 100644 index 0000000..fed6b85 --- /dev/null +++ b/src/rules/file/strictMacroDefinition.ts @@ -0,0 +1,169 @@ +import { Diagnostic, LintConfig, Macro, Severity } from '../../types' +import { FileLintRule } from '../../types/LintRule' +import { LintRuleType } from '../../types/LintRuleType' +import { parseMacros } from '../../utils/parseMacros' + +const name = 'strictMacroDefinition' +const description = 'Enforce strictly rules of macro definition syntax.' +const message = 'Incorrent Macro Definition Syntax' + +const validOptions = [ + 'CMD', + 'DES', + 'MINDELIMITER', + 'MINOPERATOR', + 'NOMINOPERATOR', + 'PARMBUFF', + 'SECURE', + 'NOSECURE', + 'STMT', + 'SOURCE', + 'SRC', + 'STORE' +] + +const processParams = ( + content: string, + macro: Macro, + diagnostics: Diagnostic[] +): string => { + const declaration = macro.declaration + + const regExpParams = new RegExp(/(?<=\().*(?=\))/) + const regExpParamsResult = regExpParams.exec(declaration) + + let _declaration = declaration + if (regExpParamsResult) { + const paramsPresent = regExpParamsResult[0] + + const params = paramsPresent.trim().split(',') + params.forEach((param) => { + const trimedParam = param.split('=')[0].trim() + + let paramLineNumber: number = 1, + paramStartIndex: number = 1, + paramEndIndex: number = content.length + + if ( + macro.declarationLines.findIndex( + (dl) => dl.indexOf(trimedParam) !== -1 + ) === -1 + ) { + const comment = '/\\*(.*?)\\*/' + for (let i = 1; i < trimedParam.length; i++) { + const paramWithComment = + trimedParam.slice(0, i) + comment + trimedParam.slice(i) + const regEx = new RegExp(paramWithComment) + + const declarationLineIndex = macro.declarationLines.findIndex( + (dl) => !!regEx.exec(dl) + ) + + if (declarationLineIndex !== -1) { + const declarationLine = macro.declarationLines[declarationLineIndex] + const partFound = regEx.exec(declarationLine)![0] + + paramLineNumber = macro.startLineNumbers[declarationLineIndex] + paramStartIndex = declarationLine.indexOf(partFound) + paramEndIndex = + declarationLine.indexOf(partFound) + partFound.length + break + } + } + } else { + const declarationLineIndex = macro.declarationLines.findIndex( + (dl) => dl.indexOf(trimedParam) !== -1 + ) + const declarationLine = macro.declarationLines[declarationLineIndex] + paramLineNumber = macro.startLineNumbers[declarationLineIndex] + + paramStartIndex = declarationLine.indexOf(trimedParam) + paramEndIndex = + declarationLine.indexOf(trimedParam) + trimedParam.length + } + + if (trimedParam.includes(' ')) { + diagnostics.push({ + message: `Param '${trimedParam}' cannot have space`, + lineNumber: paramLineNumber, + startColumnNumber: paramStartIndex + 1, + endColumnNumber: paramEndIndex, + severity: Severity.Warning + }) + } + }) + + _declaration = declaration.split(`(${paramsPresent})`)[1] + } + return _declaration +} + +const processOptions = ( + _declaration: string, + macro: Macro, + diagnostics: Diagnostic[] +): void => { + let optionsPresent = _declaration.split('/')?.[1]?.trim() + + if (optionsPresent) { + const regex = new RegExp(/="(.*?)"/, 'g') + + let result = regex.exec(optionsPresent) + + // removing Option's `="..."` part, e.g. des="..." + while (result) { + optionsPresent = + optionsPresent.slice(0, result.index) + + optionsPresent.slice(result.index + result[0].length) + + result = regex.exec(optionsPresent) + } + + optionsPresent + .split(' ') + ?.filter((o) => !!o) + .forEach((option) => { + const trimmedOption = option.trim() + if (!validOptions.includes(trimmedOption.toUpperCase())) { + const declarationLineIndex = macro.declarationLines.findIndex( + (dl) => dl.indexOf(trimmedOption) !== -1 + ) + const declarationLine = macro.declarationLines[declarationLineIndex] + + diagnostics.push({ + message: `Option '${trimmedOption}' is not valid`, + lineNumber: macro.startLineNumbers[declarationLineIndex], + startColumnNumber: declarationLine.indexOf(trimmedOption) + 1, + endColumnNumber: + declarationLine.indexOf(trimmedOption) + trimmedOption.length, + severity: Severity.Warning + }) + } + }) + } +} + +const test = (value: string, config?: LintConfig) => { + const diagnostics: Diagnostic[] = [] + + const macros = parseMacros(value, config) + + macros.forEach((macro) => { + const _declaration = processParams(value, macro, diagnostics) + + processOptions(_declaration, macro, diagnostics) + }) + + return diagnostics +} + +/** + * Lint rule that checks if a line has followed syntax for macro definition + */ +export const strictMacroDefinition: FileLintRule = { + type: LintRuleType.File, + name, + description, + message, + test +} diff --git a/src/rules/line/index.ts b/src/rules/line/index.ts index 6029956..e8b0f70 100644 --- a/src/rules/line/index.ts +++ b/src/rules/line/index.ts @@ -3,4 +3,3 @@ export { maxLineLength } from './maxLineLength' export { noEncodedPasswords } from './noEncodedPasswords' export { noTabIndentation } from './noTabIndentation' export { noTrailingSpaces } from './noTrailingSpaces' -export { strictMacroDefinition } from './strictMacroDefinition' diff --git a/src/rules/line/strictMacroDefinition.spec.ts b/src/rules/line/strictMacroDefinition.spec.ts deleted file mode 100644 index 63b0a4e..0000000 --- a/src/rules/line/strictMacroDefinition.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { LintConfig, Severity } from '../../types' -import { strictMacroDefinition } from './strictMacroDefinition' - -describe('strictMacroDefinition', () => { - it('should return an empty array when the line has correct macro definition syntax', () => { - const line = '%macro somemacro;' - expect(strictMacroDefinition.test(line, 1)).toEqual([]) - - const line2 = '%macro somemacro();' - expect(strictMacroDefinition.test(line2, 1)).toEqual([]) - - const line3 = '%macro somemacro(var1);' - expect(strictMacroDefinition.test(line3, 1)).toEqual([]) - - const line4 = '%macro somemacro/minoperator;' - expect(strictMacroDefinition.test(line4, 1)).toEqual([]) - - const line5 = '%macro somemacro /minoperator;' - expect(strictMacroDefinition.test(line5, 1)).toEqual([]) - - const line6 = '%macro somemacro(var1, var2)/minoperator;' - expect(strictMacroDefinition.test(line6, 1)).toEqual([]) - - const line7 = - ' /* Some Comment */ %macro somemacro(var1, var2) /minoperator ; /* Some Comment */' - expect(strictMacroDefinition.test(line7, 1)).toEqual([]) - }) - - it('should return an array with a single diagnostic when Macro definition has space in param', () => { - const line = '%macro somemacro(va r1);' - expect(strictMacroDefinition.test(line, 1)).toEqual([ - { - message: `Param 'va r1' cannot have space`, - lineNumber: 1, - startColumnNumber: 18, - endColumnNumber: 22, - severity: Severity.Warning - } - ]) - }) - - it('should return an array with a two diagnostics when Macro definition has space in param', () => { - const line = '%macro somemacro(var1, var 2, v ar3, var4);' - expect(strictMacroDefinition.test(line, 1)).toEqual([ - { - message: `Param 'var 2' cannot have space`, - lineNumber: 1, - startColumnNumber: 24, - endColumnNumber: 28, - severity: Severity.Warning - }, - { - message: `Param 'v ar3' cannot have space`, - lineNumber: 1, - startColumnNumber: 31, - endColumnNumber: 35, - severity: Severity.Warning - } - ]) - }) - - it('should return an array with a single diagnostic when Macro definition has invalid option', () => { - const line = '%macro somemacro(var1, var2)/minXoperator;' - expect(strictMacroDefinition.test(line, 1)).toEqual([ - { - message: `Option 'minXoperator' is not valid`, - lineNumber: 1, - startColumnNumber: 30, - endColumnNumber: 41, - severity: Severity.Warning - } - ]) - }) - - it('should return an array with a two diagnostics when Macro definition has invalid options', () => { - const line = - '%macro somemacro(var1, var2)/ store invalidoption secure ;' - expect(strictMacroDefinition.test(line, 1)).toEqual([ - { - message: `Option 'invalidoption' is not valid`, - lineNumber: 1, - startColumnNumber: 39, - endColumnNumber: 51, - severity: Severity.Warning - } - ]) - }) -}) diff --git a/src/rules/line/strictMacroDefinition.ts b/src/rules/line/strictMacroDefinition.ts deleted file mode 100644 index 9adc3cf..0000000 --- a/src/rules/line/strictMacroDefinition.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Diagnostic } from '../../types/Diagnostic' -import { LintConfig } from '../../types' -import { LineLintRule } from '../../types/LintRule' -import { LintRuleType } from '../../types/LintRuleType' -import { Severity } from '../../types/Severity' -import { parseMacros } from '../../utils/parseMacros' - -const name = 'strictMacroDefinition' -const description = 'Enforce strictly rules of macro definition syntax.' -const message = 'Incorrent Macro Definition Syntax' - -const validOptions = [ - 'CMD', - 'DES', - 'MINDELIMITER', - 'MINOPERATOR', - 'NOMINOPERATOR', - 'PARMBUFF', - 'SECURE', - 'NOSECURE', - 'STMT', - 'SOURCE', - 'SRC', - 'STORE' -] - -const test = (value: string, lineNumber: number) => { - const diagnostics: Diagnostic[] = [] - - const macros = parseMacros(value) - const declaration = macros[0]?.declaration - if (!declaration) return [] - - const regExpParams = new RegExp(/\((.*?)\)/) - const regExpParamsResult = regExpParams.exec(declaration) - - let _declaration = declaration - if (regExpParamsResult) { - const paramsPresent = regExpParamsResult[1] - - const paramsTrimmed = paramsPresent.trim() - const params = paramsTrimmed.split(',') - params.forEach((param) => { - const trimedParam = param.split('=')[0].trim() - if (trimedParam.includes(' ')) { - diagnostics.push({ - message: `Param '${trimedParam}' cannot have space`, - lineNumber, - startColumnNumber: value.indexOf(trimedParam) + 1, - endColumnNumber: value.indexOf(trimedParam) + trimedParam.length, - severity: Severity.Warning - }) - } - }) - - _declaration = declaration.split(`(${paramsPresent})`)[1] - } - - const optionsPresent = _declaration.split('/')?.[1]?.trim().split(' ') - - optionsPresent - ?.filter((o) => !!o) - .forEach((option) => { - const trimmedOption = option.trim() - if (!validOptions.includes(trimmedOption.toUpperCase())) { - diagnostics.push({ - message: `Option '${trimmedOption}' is not valid`, - lineNumber, - startColumnNumber: value.indexOf(trimmedOption) + 1, - endColumnNumber: value.indexOf(trimmedOption) + trimmedOption.length, - severity: Severity.Warning - }) - } - }) - - return diagnostics -} - -/** - * Lint rule that checks if a line has followed syntax for macro definition - */ -export const strictMacroDefinition: LineLintRule = { - type: LintRuleType.Line, - name, - description, - message, - test -} diff --git a/src/types/LintConfig.ts b/src/types/LintConfig.ts index 1ecbd06..f8fe2d3 100644 --- a/src/types/LintConfig.ts +++ b/src/types/LintConfig.ts @@ -3,15 +3,15 @@ import { hasMacroNameInMend, noNestedMacros, hasMacroParentheses, - lineEndings + lineEndings, + strictMacroDefinition } from '../rules/file' import { indentationMultiple, maxLineLength, noEncodedPasswords, noTabIndentation, - noTrailingSpaces, - strictMacroDefinition + noTrailingSpaces } from '../rules/line' import { lowerCaseFileNames, noSpacesInFileNames } from '../rules/path' import { LineEndings } from './LineEndings' @@ -93,7 +93,7 @@ export class LintConfig { } if (json?.strictMacroDefinition) { - this.lineLintRules.push(strictMacroDefinition) + this.fileLintRules.push(strictMacroDefinition) } } } diff --git a/src/types/Macro.ts b/src/types/Macro.ts new file mode 100644 index 0000000..9a44866 --- /dev/null +++ b/src/types/Macro.ts @@ -0,0 +1,12 @@ +export interface Macro { + name: string + startLineNumbers: number[] + endLineNumber: number | null + declarationLines: string[] + terminationLine: string + declaration: string + termination: string + parentMacro: string + hasMacroNameInMend: boolean + mismatchedMendMacroName: string +} diff --git a/src/types/index.ts b/src/types/index.ts index aba5333..37b9911 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,3 +4,4 @@ export * from './LintConfig' export * from './LintRule' export * from './LintRuleType' export * from './Severity' +export * from './Macro' diff --git a/src/utils/getLintConfig.spec.ts b/src/utils/getLintConfig.spec.ts index 0374ca1..75be58d 100644 --- a/src/utils/getLintConfig.spec.ts +++ b/src/utils/getLintConfig.spec.ts @@ -2,8 +2,8 @@ import * as fileModule from '@sasjs/utils/file' import { LintConfig } from '../types/LintConfig' import { getLintConfig } from './getLintConfig' -const expectedFileLintRulesCount = 4 -const expectedLineLintRulesCount = 6 +const expectedFileLintRulesCount = 5 +const expectedLineLintRulesCount = 5 const expectedPathLintRulesCount = 2 describe('getLintConfig', () => { diff --git a/src/utils/parseMacros.spec.ts b/src/utils/parseMacros.spec.ts index ce33f37..d438b75 100644 --- a/src/utils/parseMacros.spec.ts +++ b/src/utils/parseMacros.spec.ts @@ -3,173 +3,277 @@ import { parseMacros } from './parseMacros' describe('parseMacros', () => { it('should return an array with a single macro', () => { - const text = `%macro test; - %put 'hello'; -%mend` + const text = ` %macro test;\n %put 'hello';\n%mend` const macros = parseMacros(text, new LintConfig()) expect(macros.length).toEqual(1) expect(macros).toContainEqual({ name: 'test', - declarationLine: '%macro test;', + declarationLines: [' %macro test;'], terminationLine: '%mend', declaration: '%macro test', termination: '%mend', - startLineNumber: 1, + startLineNumbers: [1], endLineNumber: 3, parentMacro: '', hasMacroNameInMend: false, - hasParentheses: false, mismatchedMendMacroName: '' }) }) it('should return an array with a single macro having parameters', () => { - const text = `%macro test(var,sum); - %put 'hello'; -%mend` + const text = `%macro test(var,sum);\n %put 'hello';\n%mend` const macros = parseMacros(text, new LintConfig()) expect(macros.length).toEqual(1) expect(macros).toContainEqual({ name: 'test', - declarationLine: '%macro test(var,sum);', + declarationLines: ['%macro test(var,sum);'], terminationLine: '%mend', declaration: '%macro test(var,sum)', termination: '%mend', - startLineNumber: 1, + startLineNumbers: [1], endLineNumber: 3, parentMacro: '', hasMacroNameInMend: false, - hasParentheses: false, mismatchedMendMacroName: '' }) }) it('should return an array with a single macro having PARMBUFF option', () => { - const text = `%macro test/parmbuff; - %put 'hello'; -%mend` + const text = `%macro test/parmbuff;\n %put 'hello';\n%mend` const macros = parseMacros(text, new LintConfig()) expect(macros.length).toEqual(1) expect(macros).toContainEqual({ name: 'test', - declarationLine: '%macro test/parmbuff;', + declarationLines: ['%macro test/parmbuff;'], terminationLine: '%mend', declaration: '%macro test/parmbuff', termination: '%mend', - startLineNumber: 1, + startLineNumbers: [1], endLineNumber: 3, parentMacro: '', hasMacroNameInMend: false, - hasParentheses: false, mismatchedMendMacroName: '' }) }) it('should return an array with a single macro having paramerter & SOURCE option', () => { - const text = `/* commentary */ %macro foobar(arg) /store source - des="This macro does not do much"; - %put 'hello'; -%mend` + const text = `/* commentary */ %macro foobar(arg) /store source\n des="This macro does not do much";\n %put 'hello';\n%mend` const macros = parseMacros(text, new LintConfig()) expect(macros.length).toEqual(1) expect(macros).toContainEqual({ name: 'foobar', - declarationLine: '/* commentary */ %macro foobar(arg) /store source', + declarationLines: [ + '/* commentary */ %macro foobar(arg) /store source', + ' des="This macro does not do much";' + ], terminationLine: '%mend', - declaration: '%macro foobar(arg) /store source', + declaration: + '%macro foobar(arg) /store source des="This macro does not do much"', termination: '%mend', - startLineNumber: 1, + startLineNumbers: [1, 2], endLineNumber: 4, parentMacro: '', hasMacroNameInMend: false, - hasParentheses: false, mismatchedMendMacroName: '' }) }) it('should return an array with multiple macros', () => { - const text = `%macro foo; - %put 'foo'; -%mend; -%macro bar(); - %put 'bar'; -%mend bar;` + const text = `%macro foo;\n %put 'foo';\n%mend;\n%macro bar();\n %put 'bar';\n%mend bar;` const macros = parseMacros(text, new LintConfig()) expect(macros.length).toEqual(2) expect(macros).toContainEqual({ name: 'foo', - declarationLine: '%macro foo;', + declarationLines: ['%macro foo;'], terminationLine: '%mend;', declaration: '%macro foo', termination: '%mend', - startLineNumber: 1, + startLineNumbers: [1], endLineNumber: 3, parentMacro: '', hasMacroNameInMend: false, - hasParentheses: false, mismatchedMendMacroName: '' }) expect(macros).toContainEqual({ name: 'bar', - declarationLine: '%macro bar();', + declarationLines: ['%macro bar();'], terminationLine: '%mend bar;', declaration: '%macro bar()', termination: '%mend bar', - startLineNumber: 4, + startLineNumbers: [4], endLineNumber: 6, parentMacro: '', hasMacroNameInMend: true, - hasParentheses: true, mismatchedMendMacroName: '' }) }) it('should detect nested macro definitions', () => { - const text = `%macro test() - %put 'hello'; - %macro test2 - %put 'world; - %mend -%mend test` + const text = `%macro test();\n %put 'hello';\n %macro test2;\n %put 'world;\n %mend\n%mend test` const macros = parseMacros(text, new LintConfig()) expect(macros.length).toEqual(2) expect(macros).toContainEqual({ name: 'test', - declarationLine: '%macro test()', + declarationLines: ['%macro test();'], terminationLine: '%mend test', declaration: '%macro test()', termination: '%mend test', - startLineNumber: 1, + startLineNumbers: [1], endLineNumber: 6, parentMacro: '', hasMacroNameInMend: true, - hasParentheses: true, mismatchedMendMacroName: '' }) expect(macros).toContainEqual({ name: 'test2', - declarationLine: ' %macro test2', + declarationLines: [' %macro test2;'], terminationLine: ' %mend', declaration: '%macro test2', termination: '%mend', - startLineNumber: 3, + startLineNumbers: [3], endLineNumber: 5, parentMacro: 'test', hasMacroNameInMend: false, - hasParentheses: false, mismatchedMendMacroName: '' }) }) + + describe(`multi-line macro declarations`, () => { + it('should return an array with a single macro', () => { + const text = `%macro \n test;\n %put 'hello';\n%mend` + + const macros = parseMacros(text, new LintConfig()) + + expect(macros.length).toEqual(1) + expect(macros).toContainEqual({ + name: 'test', + declarationLines: ['%macro ', ' test;'], + terminationLine: '%mend', + declaration: '%macro test', + termination: '%mend', + startLineNumbers: [1, 2], + endLineNumber: 4, + parentMacro: '', + hasMacroNameInMend: false, + mismatchedMendMacroName: '' + }) + }) + + it('should return an array with a single macro having parameters', () => { + const text = `%macro \n test(\n var,\n sum);%put 'hello';\n%mend` + + const macros = parseMacros(text, new LintConfig()) + + expect(macros.length).toEqual(1) + expect(macros).toContainEqual({ + name: 'test', + declarationLines: [ + '%macro ', + ` test(`, + ` var,`, + ` sum);%put 'hello';` + ], + terminationLine: '%mend', + declaration: '%macro test( var, sum)', + termination: '%mend', + startLineNumbers: [1, 2, 3, 4], + endLineNumber: 5, + parentMacro: '', + hasMacroNameInMend: false, + mismatchedMendMacroName: '' + }) + }) + + it('should return an array with a single macro having PARMBUFF option', () => { + const text = `%macro test\n /parmbuff;\n %put 'hello';\n%mend` + + const macros = parseMacros(text, new LintConfig()) + + expect(macros.length).toEqual(1) + expect(macros).toContainEqual({ + name: 'test', + declarationLines: ['%macro test', ' /parmbuff;'], + terminationLine: '%mend', + declaration: '%macro test /parmbuff', + termination: '%mend', + startLineNumbers: [1, 2], + endLineNumber: 4, + parentMacro: '', + hasMacroNameInMend: false, + mismatchedMendMacroName: '' + }) + }) + + it('should return an array with a single macro having paramerter & SOURCE option', () => { + const text = `/* commentary */ %macro foobar/* commentary */(arg) \n /* commentary */\n /store\n /* commentary */source\n des="This macro does not do much";\n %put 'hello';\n%mend` + + const macros = parseMacros(text, new LintConfig()) + + expect(macros.length).toEqual(1) + expect(macros).toContainEqual({ + name: 'foobar', + declarationLines: [ + '/* commentary */ %macro foobar/* commentary */(arg) ', + ' /* commentary */', + ' /store', + ' /* commentary */source', + ' des="This macro does not do much";' + ], + terminationLine: '%mend', + declaration: + '%macro foobar(arg) /store source des="This macro does not do much"', + termination: '%mend', + startLineNumbers: [1, 2, 3, 4, 5], + endLineNumber: 7, + parentMacro: '', + hasMacroNameInMend: false, + mismatchedMendMacroName: '' + }) + }) + + it('should return an array with a single macro having semi-colon in params', () => { + const text = `\n%macro mm_createapplication(\n tree=/User Folders/sasdemo\n ,name=myApp\n ,ClassIdentifier=mcore\n ,desc=Created by mm_createapplication\n ,params= param1=1 param2=blah\n ,version=\n ,frefin=mm_in\n ,frefout=mm_out\n ,mDebug=1\n );` + + const macros = parseMacros(text, new LintConfig()) + + expect(macros.length).toEqual(1) + expect(macros).toContainEqual({ + name: 'mm_createapplication', + declarationLines: [ + `%macro mm_createapplication(`, + ` tree=/User Folders/sasdemo`, + ` ,name=myApp`, + ` ,ClassIdentifier=mcore`, + ` ,desc=Created by mm_createapplication`, + ` ,params= param1=1 param2=blah`, + ` ,version=`, + ` ,frefin=mm_in`, + ` ,frefout=mm_out`, + ` ,mDebug=1`, + ` );` + ], + terminationLine: '', + declaration: + '%macro mm_createapplication( tree=/User Folders/sasdemo ,name=myApp ,ClassIdentifier=mcore ,desc=Created by mm_createapplication ,params= param1=1 param2=blah ,version= ,frefin=mm_in ,frefout=mm_out ,mDebug=1 )', + termination: '', + startLineNumbers: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + endLineNumber: null, + parentMacro: '', + hasMacroNameInMend: false, + mismatchedMendMacroName: '' + }) + }) + }) }) diff --git a/src/utils/parseMacros.ts b/src/utils/parseMacros.ts index 656dc41..2147e53 100644 --- a/src/utils/parseMacros.ts +++ b/src/utils/parseMacros.ts @@ -1,21 +1,7 @@ -import { LintConfig } from '../types/LintConfig' +import { LintConfig, Macro } from '../types' import { LineEndings } from '../types/LineEndings' import { trimComments } from './trimComments' -interface Macro { - name: string - startLineNumber: number | null - endLineNumber: number | null - declarationLine: string - terminationLine: string - declaration: string - termination: string - parentMacro: string - hasMacroNameInMend: boolean - hasParentheses: boolean - mismatchedMendMacroName: string -} - export const parseMacros = (text: string, config?: LintConfig): Macro[] => { const lineEnding = config?.lineEndings === LineEndings.CRLF ? '\r\n' : '\n' const lines: string[] = text ? text.split(lineEnding) : [] @@ -23,39 +9,107 @@ export const parseMacros = (text: string, config?: LintConfig): Macro[] => { let isCommentStarted = false let macroStack: Macro[] = [] - lines.forEach((line, index) => { + let isReadingMacroDefinition = false + let isStatementContinues = true + let tempMacroDeclaration = '' + let tempMacroDeclarationLines: string[] = [] + let tempStartLineNumbers: number[] = [] + lines.forEach((line, lineIndex) => { const { statement: trimmedLine, commentStarted } = trimComments( line, isCommentStarted ) isCommentStarted = commentStarted - const statements: string[] = trimmedLine ? trimmedLine.split(';') : [] - statements.forEach((statement) => { + isStatementContinues = !trimmedLine.endsWith(';') + + const statements: string[] = trimmedLine.split(';') + + if (isReadingMacroDefinition) { + // checking if code is split into statements based on `;` is a part of HTML Encoded Character + // if it happened, merges two statements into one + statements.forEach((statement, statementIndex) => { + if (/&[^\s]{1,5}$/.test(statement)) { + const next = statements[statementIndex] + const updatedStatement = `${statement};${ + statements[statementIndex + 1] + }` + statements.splice(statementIndex, 1, updatedStatement) + statements.splice(statementIndex + 1, 1) + } + }) + } + + statements.forEach((statement, statementIndex) => { const { statement: trimmedStatement, commentStarted } = trimComments( statement, isCommentStarted ) isCommentStarted = commentStarted + if (isReadingMacroDefinition) { + tempMacroDeclaration = + tempMacroDeclaration + + (trimmedStatement ? ' ' + trimmedStatement : '') + tempMacroDeclarationLines.push(line) + tempStartLineNumbers.push(lineIndex + 1) + + if (!Object.is(statements.length - 1, statementIndex)) { + isReadingMacroDefinition = false + + const name = tempMacroDeclaration + .slice(7, tempMacroDeclaration.length) + .trim() + .split('/')[0] + .split('(')[0] + .trim() + macroStack.push({ + name, + startLineNumbers: tempStartLineNumbers, + endLineNumber: null, + parentMacro: macroStack.length + ? macroStack[macroStack.length - 1].name + : '', + hasMacroNameInMend: false, + mismatchedMendMacroName: '', + declarationLines: tempMacroDeclarationLines, + terminationLine: '', + declaration: tempMacroDeclaration, + termination: '' + }) + } + } + if (trimmedStatement.startsWith('%macro')) { - const startLineNumber = index + 1 + const startLineNumber = lineIndex + 1 + + if ( + isStatementContinues && + Object.is(statements.length - 1, statementIndex) + ) { + tempMacroDeclaration = trimmedStatement + tempMacroDeclarationLines = [line] + tempStartLineNumbers = [startLineNumber] + isReadingMacroDefinition = true + return + } + const name = trimmedStatement .slice(7, trimmedStatement.length) .trim() .split('/')[0] .split('(')[0] + .trim() macroStack.push({ name, - startLineNumber, + startLineNumbers: [startLineNumber], endLineNumber: null, parentMacro: macroStack.length ? macroStack[macroStack.length - 1].name : '', - hasParentheses: trimmedStatement.endsWith('()'), hasMacroNameInMend: false, mismatchedMendMacroName: '', - declarationLine: line, + declarationLines: [line], terminationLine: '', declaration: trimmedStatement, termination: '' @@ -65,7 +119,7 @@ export const parseMacros = (text: string, config?: LintConfig): Macro[] => { const macro = macroStack.pop() as Macro const mendMacroName = trimmedStatement.split(' ').filter((s: string) => !!s)[1] || '' - macro.endLineNumber = index + 1 + macro.endLineNumber = lineIndex + 1 macro.hasMacroNameInMend = mendMacroName === macro.name macro.mismatchedMendMacroName = macro.hasMacroNameInMend ? '' @@ -76,13 +130,12 @@ export const parseMacros = (text: string, config?: LintConfig): Macro[] => { } else { macros.push({ name: '', - startLineNumber: null, - endLineNumber: index + 1, + startLineNumbers: [], + endLineNumber: lineIndex + 1, parentMacro: '', - hasParentheses: false, hasMacroNameInMend: false, mismatchedMendMacroName: '', - declarationLine: '', + declarationLines: [], terminationLine: line, declaration: '', termination: trimmedStatement diff --git a/src/utils/trimComments.spec.ts b/src/utils/trimComments.spec.ts index 4ede9c3..6efaaff 100644 --- a/src/utils/trimComments.spec.ts +++ b/src/utils/trimComments.spec.ts @@ -7,6 +7,27 @@ describe('trimComments', () => { /* some comment */ some code; `) ).toEqual({ statement: 'some code;', commentStarted: false }) + + expect( + trimComments(` + /*/ some comment */ some code; + `) + ).toEqual({ statement: 'some code;', commentStarted: false }) + + expect( + trimComments(` + some code;/*/ some comment */ some code; + `) + ).toEqual({ statement: 'some code; some code;', commentStarted: false }) + + expect( + trimComments(`/* some comment */ + /* some comment */ CODE_Keyword1 /* some comment */ CODE_Keyword2/* some comment */;/* some comment */ + /* some comment */`) + ).toEqual({ + statement: 'CODE_Keyword1 CODE_Keyword2;', + commentStarted: false + }) }) it('should return statment, having multi-line comment', () => { diff --git a/src/utils/trimComments.ts b/src/utils/trimComments.ts index 670de7a..0123919 100644 --- a/src/utils/trimComments.ts +++ b/src/utils/trimComments.ts @@ -1,11 +1,14 @@ export const trimComments = ( statement: string, - commentStarted: boolean = false + commentStarted: boolean = false, + trimEnd: boolean = false ): { statement: string; commentStarted: boolean } => { - let trimmed = (statement || '').trim() + let trimmed = trimEnd ? (statement || '').trimEnd() : (statement || '').trim() if (commentStarted || trimmed.startsWith('/*')) { - const parts = trimmed.split('*/') + const parts = trimmed.startsWith('/*') + ? trimmed.slice(2).split('*/') + : trimmed.split('*/') if (parts.length === 2) { return { statement: (parts.pop() as string).trim(), @@ -17,6 +20,19 @@ export const trimComments = ( } else { return { statement: '', commentStarted: true } } + } else if (trimmed.includes('/*')) { + const statementBeforeCommentStarts = trimmed.slice(0, trimmed.indexOf('/*')) + trimmed = trimmed.slice(trimmed.indexOf('/*') + 2) + const remainingStatement = trimmed.slice(trimmed.indexOf('*/') + 2) + + const result = trimComments(remainingStatement, false, true) + const completeStatement = statementBeforeCommentStarts + result.statement + return { + statement: trimEnd + ? completeStatement.trimEnd() + : completeStatement.trim(), + commentStarted: result.commentStarted + } } return { statement: trimmed, commentStarted: false } }