From 519a0164b5ba0c8952eb0c80466f20a807a562f2 Mon Sep 17 00:00:00 2001 From: Krishna Acondy Date: Mon, 19 Apr 2021 21:00:38 +0100 Subject: [PATCH 01/12] feat(*): add line endings rule, add automatic formatting for fixable violations --- sasjslint-schema.json | 14 +- src/format.ts | 1 - src/format/formatText.spec.ts | 48 +++++++ src/format/formatText.ts | 7 + src/format/shared.ts | 37 +++++ src/formatExample.ts | 21 +++ src/lint/lintProject.spec.ts | 10 +- src/lint/lintProject.ts | 3 +- src/lint/shared.spec.ts | 25 ---- src/lint/shared.ts | 13 +- src/{example.ts => lintExample.ts} | 0 src/rules/file/hasDoxygenHeader.spec.ts | 40 ++++++ src/rules/file/hasDoxygenHeader.ts | 20 ++- src/rules/file/hasMacroNameInMend.spec.ts | 41 ++++++ src/rules/file/hasMacroNameInMend.ts | 159 ++++++++++----------- src/rules/file/hasMacroParentheses.spec.ts | 7 +- src/rules/file/hasMacroParentheses.ts | 99 +++++-------- src/rules/file/index.ts | 1 + src/rules/file/lineEndings.spec.ts | 139 ++++++++++++++++++ src/rules/file/lineEndings.ts | 83 +++++++++++ src/rules/file/noNestedMacros.spec.ts | 54 ++++--- src/rules/file/noNestedMacros.ts | 72 ++++------ src/rules/line/noTrailingSpaces.ts | 4 +- src/types/LineEndings.ts | 4 + src/types/LintConfig.spec.ts | 28 ++++ src/types/LintConfig.ts | 18 ++- src/types/LintRule.ts | 4 +- src/utils/index.ts | 1 + src/utils/parseMacros.spec.ts | 95 ++++++++++++ src/utils/parseMacros.ts | 90 ++++++++++++ src/utils/splitText.spec.ts | 41 ++++++ src/utils/splitText.ts | 17 +++ 32 files changed, 939 insertions(+), 257 deletions(-) delete mode 100644 src/format.ts create mode 100644 src/format/formatText.spec.ts create mode 100644 src/format/formatText.ts create mode 100644 src/format/shared.ts create mode 100644 src/formatExample.ts delete mode 100644 src/lint/shared.spec.ts rename src/{example.ts => lintExample.ts} (100%) create mode 100644 src/rules/file/lineEndings.spec.ts create mode 100644 src/rules/file/lineEndings.ts create mode 100644 src/types/LineEndings.ts create mode 100644 src/utils/parseMacros.spec.ts create mode 100644 src/utils/parseMacros.ts create mode 100644 src/utils/splitText.spec.ts create mode 100644 src/utils/splitText.ts diff --git a/sasjslint-schema.json b/sasjslint-schema.json index e01f628..4483d53 100644 --- a/sasjslint-schema.json +++ b/sasjslint-schema.json @@ -15,7 +15,8 @@ "noNestedMacros": true, "noSpacesInFileNames": true, "noTabIndentation": true, - "noTrailingSpaces": true + "noTrailingSpaces": true, + "lineEndings": "lf" }, "examples": [ { @@ -29,7 +30,8 @@ "indentationMultiple": 4, "hasMacroNameInMend": true, "noNestedMacros": true, - "hasMacroParentheses": true + "hasMacroParentheses": true, + "lineEndings": "crlf" } ], "properties": { @@ -120,6 +122,14 @@ "description": "Enforces no trailing spaces in lines of SAS code. Shows a warning when they are present.", "default": true, "examples": [true, false] + }, + "lineEndings": { + "$id": "#/properties/lineEndings", + "type": "string", + "title": "lineEndings", + "description": "Enforces the configured terminating character for each line. Shows a warning when incorrect line endings are present.", + "default": "lf", + "examples": ["lf", "crlf"] } } } diff --git a/src/format.ts b/src/format.ts deleted file mode 100644 index 96d852f..0000000 --- a/src/format.ts +++ /dev/null @@ -1 +0,0 @@ -export const format = (text: string) => {} diff --git a/src/format/formatText.spec.ts b/src/format/formatText.spec.ts new file mode 100644 index 0000000..0263887 --- /dev/null +++ b/src/format/formatText.spec.ts @@ -0,0 +1,48 @@ +import { formatText } from './formatText' +import * as getLintConfigModule from '../utils/getLintConfig' +import { LintConfig } from '../types' +jest.mock('../utils/getLintConfig') + +describe('formatText', () => { + it('should format the given text based on configured rules', async () => { + jest + .spyOn(getLintConfigModule, 'getLintConfig') + .mockImplementationOnce(() => + Promise.resolve( + new LintConfig(getLintConfigModule.DefaultLintConfiguration) + ) + ) + const text = `%macro test + %put 'hello';\r\n%mend; ` + + const expectedOutput = `/** + @file + @brief +**/\n%macro test + %put 'hello';\n%mend test;\n` + + const output = await formatText(text) + + expect(output).toEqual(expectedOutput) + }) + + it('should use CRLF line endings when configured', async () => { + jest + .spyOn(getLintConfigModule, 'getLintConfig') + .mockImplementationOnce(() => + Promise.resolve( + new LintConfig({ + ...getLintConfigModule.DefaultLintConfiguration, + lineEndings: 'crlf' + }) + ) + ) + const text = `%macro test\n %put 'hello';\r\n%mend; ` + + const expectedOutput = `/**\r\n @file\r\n @brief \r\n**/\r\n%macro test\r\n %put 'hello';\r\n%mend test;\r\n` + + const output = await formatText(text) + + expect(output).toEqual(expectedOutput) + }) +}) diff --git a/src/format/formatText.ts b/src/format/formatText.ts new file mode 100644 index 0000000..b33807e --- /dev/null +++ b/src/format/formatText.ts @@ -0,0 +1,7 @@ +import { getLintConfig } from '../utils' +import { processText } from './shared' + +export const formatText = async (text: string) => { + const config = await getLintConfig() + return processText(text, config) +} diff --git a/src/format/shared.ts b/src/format/shared.ts new file mode 100644 index 0000000..fa0ff02 --- /dev/null +++ b/src/format/shared.ts @@ -0,0 +1,37 @@ +import { LintConfig } from '../types' +import { LineEndings } from '../types/LineEndings' +import { splitText } from '../utils/splitText' + +export const processText = (text: string, config: LintConfig) => { + const processedText = processContent(config, text) + const lines = splitText(processedText, config) + const formattedLines = lines.map((line) => { + return processLine(config, line) + }) + + const configuredLineEnding = + config.lineEndings === LineEndings.LF ? '\n' : '\r\n' + return formattedLines.join(configuredLineEnding) +} + +const processContent = (config: LintConfig, content: string): string => { + let processedContent = content + config.fileLintRules + .filter((r) => !!r.fix) + .forEach((rule) => { + processedContent = rule.fix!(processedContent) + }) + + return processedContent +} + +export const processLine = (config: LintConfig, line: string): string => { + let processedLine = line + config.lineLintRules + .filter((r) => !!r.fix) + .forEach((rule) => { + processedLine = rule.fix!(line) + }) + + return processedLine +} diff --git a/src/formatExample.ts b/src/formatExample.ts new file mode 100644 index 0000000..10a428c --- /dev/null +++ b/src/formatExample.ts @@ -0,0 +1,21 @@ +import { formatText } from './format/formatText' +import { lintText } from './lint' + +const content = `%put 'Hello'; +%put 'World'; +%macro somemacro() + %put 'test'; +%mend;\r\n` + +console.log(content) +lintText(content).then((diagnostics) => { + console.log('Before Formatting:') + console.table(diagnostics) + formatText(content).then((formattedText) => { + lintText(formattedText).then((newDiagnostics) => { + console.log('After Formatting:') + console.log(formattedText) + console.table(newDiagnostics) + }) + }) +}) diff --git a/src/lint/lintProject.spec.ts b/src/lint/lintProject.spec.ts index 47e09d9..ec7245f 100644 --- a/src/lint/lintProject.spec.ts +++ b/src/lint/lintProject.spec.ts @@ -1,8 +1,8 @@ import { lintProject } from './lintProject' import { Severity } from '../types/Severity' -import * as utils from '../utils' +import * as getProjectRootModule from '../utils/getProjectRoot' import path from 'path' -jest.mock('../utils') +jest.mock('../utils/getProjectRoot') const expectedFilesCount = 1 const expectedDiagnostics = [ @@ -74,8 +74,8 @@ const expectedDiagnostics = [ describe('lintProject', () => { it('should identify lint issues in a given project', async () => { jest - .spyOn(utils, 'getProjectRoot') - .mockImplementationOnce(() => Promise.resolve(path.join(__dirname, '..'))) + .spyOn(getProjectRootModule, 'getProjectRoot') + .mockImplementation(() => Promise.resolve(path.join(__dirname, '..'))) const results = await lintProject() expect(results.size).toEqual(expectedFilesCount) @@ -96,7 +96,7 @@ describe('lintProject', () => { it('should throw an error when a project root is not found', async () => { jest - .spyOn(utils, 'getProjectRoot') + .spyOn(getProjectRootModule, 'getProjectRoot') .mockImplementationOnce(() => Promise.resolve('')) await expect(lintProject()).rejects.toThrowError( diff --git a/src/lint/lintProject.ts b/src/lint/lintProject.ts index 2c9c524..89eeafd 100644 --- a/src/lint/lintProject.ts +++ b/src/lint/lintProject.ts @@ -1,4 +1,4 @@ -import { getProjectRoot } from '../utils' +import { getProjectRoot } from '../utils/getProjectRoot' import { lintFolder } from './lintFolder' /** @@ -8,7 +8,6 @@ import { lintFolder } from './lintFolder' export const lintProject = async () => { const projectRoot = (await getProjectRoot()) || process.projectDir || process.currentDir - if (!projectRoot) { throw new Error('SASjs Project Root was not found.') } diff --git a/src/lint/shared.spec.ts b/src/lint/shared.spec.ts deleted file mode 100644 index 668610b..0000000 --- a/src/lint/shared.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { splitText } from './shared' - -describe('splitText', () => { - it('should return an empty array when text is falsy', () => { - const lines = splitText('') - - expect(lines.length).toEqual(0) - }) - - it('should return an array of lines from text', () => { - const lines = splitText(`line 1\nline 2`) - - expect(lines.length).toEqual(2) - expect(lines[0]).toEqual('line 1') - expect(lines[1]).toEqual('line 2') - }) - - it('should work with CRLF line endings', () => { - const lines = splitText(`line 1\r\nline 2`) - - expect(lines.length).toEqual(2) - expect(lines[0]).toEqual('line 1') - expect(lines[1]).toEqual('line 2') - }) -}) diff --git a/src/lint/shared.ts b/src/lint/shared.ts index bbbadc6..50ca081 100644 --- a/src/lint/shared.ts +++ b/src/lint/shared.ts @@ -1,17 +1,8 @@ import { LintConfig, Diagnostic } from '../types' - -/** - * Splits the given content into a list of lines, regardless of CRLF or LF line endings. - * @param {string} text - the text content to be split into lines. - * @returns {string[]} an array of lines from the given text - */ -export const splitText = (text: string): string[] => { - if (!text) return [] - return text.replace(/\r\n/g, '\n').split('\n') -} +import { splitText } from '../utils' export const processText = (text: string, config: LintConfig) => { - const lines = splitText(text) + const lines = splitText(text, config) const diagnostics: Diagnostic[] = [] diagnostics.push(...processContent(config, text)) lines.forEach((line, index) => { diff --git a/src/example.ts b/src/lintExample.ts similarity index 100% rename from src/example.ts rename to src/lintExample.ts diff --git a/src/rules/file/hasDoxygenHeader.spec.ts b/src/rules/file/hasDoxygenHeader.spec.ts index 2bf1259..9f66a73 100644 --- a/src/rules/file/hasDoxygenHeader.spec.ts +++ b/src/rules/file/hasDoxygenHeader.spec.ts @@ -1,3 +1,4 @@ +import { LintConfig } from '../../types' import { Severity } from '../../types/Severity' import { hasDoxygenHeader } from './hasDoxygenHeader' @@ -68,4 +69,43 @@ describe('hasDoxygenHeader', () => { } ]) }) + + it('should not alter the text if a doxygen header is already present', () => { + const content = `/** + @file + @brief Returns an unused libref + **/ + + %macro mf_getuniquelibref(prefix=mclib,maxtries=1000); + %local x libref; + %let x={SAS002}; + %do x=0 %to &maxtries;` + + expect(hasDoxygenHeader.fix!(content)).toEqual(content) + }) + + it('should should add a doxygen header if not present', () => { + const content = `%macro mf_getuniquelibref(prefix=mclib,maxtries=1000); +%local x libref; +%let x={SAS002}; +%do x=0 %to &maxtries;` + + expect(hasDoxygenHeader.fix!(content)).toEqual( + `/** + @file + @brief +**/` + + '\n' + + content + ) + }) + + it('should use CRLF line endings when configured', () => { + const content = `%macro mf_getuniquelibref(prefix=mclib,maxtries=1000);\n%local x libref;\n%let x={SAS002};\n%do x=0 %to &maxtries;` + const config = new LintConfig({ lineEndings: 'crlf' }) + + expect(hasDoxygenHeader.fix!(content, config)).toEqual( + `/**\r\n @file\r\n @brief \r\n**/` + '\r\n' + content + ) + }) }) diff --git a/src/rules/file/hasDoxygenHeader.ts b/src/rules/file/hasDoxygenHeader.ts index aa7c0bc..b6635e6 100644 --- a/src/rules/file/hasDoxygenHeader.ts +++ b/src/rules/file/hasDoxygenHeader.ts @@ -1,7 +1,11 @@ +import { LintConfig } from '../../types' +import { LineEndings } from '../../types/LineEndings' import { FileLintRule } from '../../types/LintRule' import { LintRuleType } from '../../types/LintRuleType' import { Severity } from '../../types/Severity' +const DoxygenHeader = `/**{lineEnding} @file{lineEnding} @brief {lineEnding}**/` + const name = 'hasDoxygenHeader' const description = 'Enforce the presence of a Doxygen header at the start of each file.' @@ -32,6 +36,19 @@ const test = (value: string) => { } } +const fix = (value: string, config?: LintConfig): string => { + if (test(value).length === 0) { + return value + } + const lineEndingConfig = config?.lineEndings || LineEndings.LF + const lineEnding = lineEndingConfig === LineEndings.LF ? '\n' : '\r\n' + + return `${DoxygenHeader.replace( + /{lineEnding}/g, + lineEnding + )}${lineEnding}${value}` +} + /** * Lint rule that checks for the presence of a Doxygen header in a given file. */ @@ -40,5 +57,6 @@ export const hasDoxygenHeader: FileLintRule = { name, description, message, - test + test, + fix } diff --git a/src/rules/file/hasMacroNameInMend.spec.ts b/src/rules/file/hasMacroNameInMend.spec.ts index 81d3b2e..7df322d 100644 --- a/src/rules/file/hasMacroNameInMend.spec.ts +++ b/src/rules/file/hasMacroNameInMend.spec.ts @@ -1,3 +1,4 @@ +import { LintConfig } from '../../types' import { Severity } from '../../types/Severity' import { hasMacroNameInMend } from './hasMacroNameInMend' @@ -319,4 +320,44 @@ describe('hasMacroNameInMend', () => { }) }) }) + + it('should use the configured line ending while testing content', () => { + const content = `%macro somemacro();\r\n%put &sysmacroname;\r\n%mend;` + + const diagnostics = hasMacroNameInMend.test( + content, + new LintConfig({ lineEndings: 'crlf' }) + ) + + expect(diagnostics).toEqual([ + { + message: '%mend statement is missing macro name - somemacro', + lineNumber: 3, + startColumnNumber: 1, + endColumnNumber: 7, + severity: Severity.Warning + } + ]) + }) + + it('should add macro name to the mend statement if not present', () => { + const content = `%macro somemacro();\n%put &sysmacroname;\n%mend;` + const expectedContent = `%macro somemacro();\n%put &sysmacroname;\n%mend somemacro;\n` + + const formattedContent = hasMacroNameInMend.fix!(content, new LintConfig()) + + expect(formattedContent).toEqual(expectedContent) + }) + + it('should use the configured line ending while applying the fix', () => { + const content = `%macro somemacro();\r\n%put &sysmacroname;\r\n%mend;` + const expectedContent = `%macro somemacro();\r\n%put &sysmacroname;\r\n%mend somemacro;\r\n` + + const formattedContent = hasMacroNameInMend.fix!( + content, + new LintConfig({ lineEndings: 'crlf' }) + ) + + expect(formattedContent).toEqual(expectedContent) + }) }) diff --git a/src/rules/file/hasMacroNameInMend.ts b/src/rules/file/hasMacroNameInMend.ts index fec97cb..56a4a75 100644 --- a/src/rules/file/hasMacroNameInMend.ts +++ b/src/rules/file/hasMacroNameInMend.ts @@ -2,97 +2,95 @@ import { Diagnostic } from '../../types/Diagnostic' import { FileLintRule } from '../../types/LintRule' import { LintRuleType } from '../../types/LintRuleType' import { Severity } from '../../types/Severity' -import { trimComments } from '../../utils/trimComments' import { getColumnNumber } from '../../utils/getColumnNumber' +import { LintConfig } from '../../types' +import { LineEndings } from '../../types/LineEndings' +import { parseMacros } from '../../utils/parseMacros' const name = 'hasMacroNameInMend' const description = 'Enforces the presence of the macro name in each %mend statement.' const message = '%mend statement has missing or incorrect macro name' -const test = (value: string) => { +const test = (value: string, config?: LintConfig) => { + const lineEnding = config?.lineEndings === LineEndings.CRLF ? '\r\n' : '\n' + const lines: string[] = value ? value.split(lineEnding) : [] + const macros = parseMacros(value, config) const diagnostics: Diagnostic[] = [] + macros.forEach((macro) => { + if (macro.startLineNumber === null && macro.endLineNumber !== null) { + diagnostics.push({ + message: `%mend statement is redundant`, + lineNumber: macro.endLineNumber, + startColumnNumber: getColumnNumber( + lines[macro.endLineNumber - 1], + '%mend' + ), + endColumnNumber: + getColumnNumber(lines[macro.endLineNumber - 1], '%mend') + + lines[macro.endLineNumber - 1].trim().length - + 1, + severity: Severity.Warning + }) + } else if (macro.endLineNumber === null && macro.startLineNumber !== null) { + diagnostics.push({ + message: `Missing %mend statement for macro - ${macro.name}`, + lineNumber: macro.startLineNumber, + startColumnNumber: 1, + endColumnNumber: 1, + severity: Severity.Warning + }) + } else if (macro.mismatchedMendMacroName) { + diagnostics.push({ + message: `%mend statement has mismatched macro name, it should be '${ + macro!.name + }'`, + lineNumber: macro.endLineNumber as number, + startColumnNumber: getColumnNumber( + lines[(macro.endLineNumber as number) - 1], + macro.mismatchedMendMacroName + ), + endColumnNumber: + getColumnNumber( + lines[(macro.endLineNumber as number) - 1], + macro.mismatchedMendMacroName + ) + + macro.mismatchedMendMacroName.length - + 1, + severity: Severity.Warning + }) + } else if (!macro.hasMacroNameInMend) { + diagnostics.push({ + message: `%mend statement is missing macro name - ${macro.name}`, + lineNumber: macro.endLineNumber as number, + startColumnNumber: getColumnNumber( + lines[(macro.endLineNumber as number) - 1], + '%mend' + ), + endColumnNumber: + getColumnNumber(lines[(macro.endLineNumber as number) - 1], '%mend') + + 6, + severity: Severity.Warning + }) + } + }) - const lines: string[] = value ? value.split('\n') : [] - - const declaredMacros: { name: string; lineNumber: number }[] = [] - let isCommentStarted = false - lines.forEach((line, lineIndex) => { - const { statement: trimmedLine, commentStarted } = trimComments( - line, - isCommentStarted - ) - isCommentStarted = commentStarted - const statements: string[] = trimmedLine ? trimmedLine.split(';') : [] + return diagnostics +} - statements.forEach((statement) => { - const { statement: trimmedStatement, commentStarted } = trimComments( - statement, - isCommentStarted +const fix = (value: string, config?: LintConfig): string => { + const lineEnding = config?.lineEndings === LineEndings.CRLF ? '\r\n' : '\n' + let formattedText = value + const macros = parseMacros(value, config) + macros + .filter((macro) => !macro.hasMacroNameInMend) + .forEach((macro) => { + formattedText = formattedText.replace( + macro.termination, + `%mend ${macro.name};${lineEnding}` ) - isCommentStarted = commentStarted - - if (trimmedStatement.startsWith('%macro ')) { - const macroName = trimmedStatement - .slice(7, trimmedStatement.length) - .trim() - .split('(')[0] - if (macroName) - declaredMacros.push({ - name: macroName, - lineNumber: lineIndex + 1 - }) - } else if (trimmedStatement.startsWith('%mend')) { - const declaredMacro = declaredMacros.pop() - const macroName = trimmedStatement - .split(' ') - .filter((s: string) => !!s)[1] - - if (!declaredMacro) { - diagnostics.push({ - message: `%mend statement is redundant`, - lineNumber: lineIndex + 1, - startColumnNumber: getColumnNumber(line, '%mend'), - endColumnNumber: - getColumnNumber(line, '%mend') + trimmedStatement.length, - severity: Severity.Warning - }) - } else if (!macroName) { - diagnostics.push({ - message: `%mend statement is missing macro name - ${ - declaredMacro!.name - }`, - lineNumber: lineIndex + 1, - startColumnNumber: getColumnNumber(line, '%mend'), - endColumnNumber: getColumnNumber(line, '%mend') + 6, - severity: Severity.Warning - }) - } else if (macroName !== declaredMacro!.name) { - diagnostics.push({ - message: `%mend statement has mismatched macro name, it should be '${ - declaredMacro!.name - }'`, - lineNumber: lineIndex + 1, - startColumnNumber: getColumnNumber(line, macroName), - endColumnNumber: - getColumnNumber(line, macroName) + macroName.length - 1, - severity: Severity.Warning - }) - } - } }) - }) - declaredMacros.forEach((declaredMacro) => { - diagnostics.push({ - message: `Missing %mend statement for macro - ${declaredMacro.name}`, - lineNumber: declaredMacro.lineNumber, - startColumnNumber: 1, - endColumnNumber: 1, - severity: Severity.Warning - }) - }) - - return diagnostics + return formattedText } /** @@ -103,5 +101,6 @@ export const hasMacroNameInMend: FileLintRule = { name, description, message, - test + test, + fix } diff --git a/src/rules/file/hasMacroParentheses.spec.ts b/src/rules/file/hasMacroParentheses.spec.ts index 438f054..f59b6ce 100644 --- a/src/rules/file/hasMacroParentheses.spec.ts +++ b/src/rules/file/hasMacroParentheses.spec.ts @@ -16,7 +16,6 @@ describe('hasMacroParentheses', () => { %macro somemacro; %put &sysmacroname; %mend somemacro;` - expect(hasMacroParentheses.test(content)).toEqual([ { message: 'Macro definition missing parentheses', @@ -28,7 +27,7 @@ describe('hasMacroParentheses', () => { ]) }) - it('should return an array with a single diagnostics when macro defined without name', () => { + it('should return an array with a single diagnostic when macro defined without name', () => { const content = ` %macro (); %put &sysmacroname; @@ -45,7 +44,7 @@ describe('hasMacroParentheses', () => { ]) }) - it('should return an array with a single diagnostics when macro defined without name and parentheses', () => { + it('should return an array with a single diagnostic when macro defined without name and parentheses', () => { const content = ` %macro ; %put &sysmacroname; @@ -56,7 +55,7 @@ describe('hasMacroParentheses', () => { message: 'Macro definition missing name', lineNumber: 2, startColumnNumber: 3, - endColumnNumber: 9, + endColumnNumber: 10, severity: Severity.Warning } ]) diff --git a/src/rules/file/hasMacroParentheses.ts b/src/rules/file/hasMacroParentheses.ts index 9597674..f2eef10 100644 --- a/src/rules/file/hasMacroParentheses.ts +++ b/src/rules/file/hasMacroParentheses.ts @@ -2,74 +2,51 @@ import { Diagnostic } from '../../types/Diagnostic' import { FileLintRule } from '../../types/LintRule' import { LintRuleType } from '../../types/LintRuleType' import { Severity } from '../../types/Severity' -import { trimComments } from '../../utils/trimComments' import { getColumnNumber } from '../../utils/getColumnNumber' +import { parseMacros } from '../../utils/parseMacros' +import { LintConfig } from '../../types' const name = 'hasMacroParentheses' const description = 'Enforces the presence of parentheses in macro definitions.' const message = 'Macro definition missing parentheses' -const test = (value: string) => { +const test = (value: string, config?: LintConfig) => { const diagnostics: Diagnostic[] = [] - - const lines: string[] = value ? value.split('\n') : [] - let isCommentStarted = false - lines.forEach((line, lineIndex) => { - const { statement: trimmedLine, commentStarted } = trimComments( - line, - isCommentStarted - ) - isCommentStarted = commentStarted - const statements: string[] = trimmedLine ? trimmedLine.split(';') : [] - - statements.forEach((statement) => { - const { statement: trimmedStatement, commentStarted } = trimComments( - statement, - isCommentStarted - ) - isCommentStarted = commentStarted - - if (trimmedStatement.startsWith('%macro')) { - const macroNameDefinition = trimmedStatement - .slice(7, trimmedStatement.length) - .trim() - - const macroNameDefinitionParts = macroNameDefinition.split('(') - const macroName = macroNameDefinitionParts[0] - - if (!macroName) - diagnostics.push({ - message: 'Macro definition missing name', - lineNumber: lineIndex + 1, - startColumnNumber: getColumnNumber(line, '%macro'), - endColumnNumber: - getColumnNumber(line, '%macro') + trimmedStatement.length, - severity: Severity.Warning - }) - else if (macroNameDefinitionParts.length === 1) - diagnostics.push({ - message, - lineNumber: lineIndex + 1, - startColumnNumber: getColumnNumber(line, macroNameDefinition), - endColumnNumber: - getColumnNumber(line, macroNameDefinition) + - macroNameDefinition.length - - 1, - severity: Severity.Warning - }) - else if (macroName !== macroName.trim()) - diagnostics.push({ - message: 'Macro definition contains space(s)', - lineNumber: lineIndex + 1, - startColumnNumber: getColumnNumber(line, macroNameDefinition), - endColumnNumber: - getColumnNumber(line, macroNameDefinition) + - macroNameDefinition.length - - 1, - severity: Severity.Warning - }) - } - }) + const macros = parseMacros(value, config) + macros.forEach((macro) => { + if (!macro.name) { + diagnostics.push({ + message: 'Macro definition missing name', + lineNumber: macro.startLineNumber!, + startColumnNumber: getColumnNumber(macro.declaration, '%macro'), + endColumnNumber: macro.declaration.length, + severity: Severity.Warning + }) + } else if (!macro.declaration.includes('(')) { + diagnostics.push({ + message, + lineNumber: macro.startLineNumber!, + startColumnNumber: getColumnNumber(macro.declaration, macro.name), + endColumnNumber: + getColumnNumber(macro.declaration, 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.declaration, macro.name), + endColumnNumber: + getColumnNumber(macro.declaration, macro.name) + + macro.name.length - + 1 + + `()`.length, + severity: Severity.Warning + }) + } }) + return diagnostics } diff --git a/src/rules/file/index.ts b/src/rules/file/index.ts index e551bdd..40730af 100644 --- a/src/rules/file/index.ts +++ b/src/rules/file/index.ts @@ -1,4 +1,5 @@ export { hasDoxygenHeader } from './hasDoxygenHeader' export { hasMacroNameInMend } from './hasMacroNameInMend' export { hasMacroParentheses } from './hasMacroParentheses' +export { lineEndings } from './lineEndings' export { noNestedMacros } from './noNestedMacros' diff --git a/src/rules/file/lineEndings.spec.ts b/src/rules/file/lineEndings.spec.ts new file mode 100644 index 0000000..d50bf24 --- /dev/null +++ b/src/rules/file/lineEndings.spec.ts @@ -0,0 +1,139 @@ +import { LintConfig, Severity } from '../../types' +import { LineEndings } from '../../types/LineEndings' +import { lineEndings } from './lineEndings' + +describe('lineEndings', () => { + it('should return an empty array when the text contains the configured line endings', () => { + const text = "%put 'hello';\n%put 'world';\n" + const config = new LintConfig({ lineEndings: LineEndings.LF }) + expect(lineEndings.test(text, config)).toEqual([]) + }) + + it('should return an array with a single diagnostic when a line is terminated with a CRLF ending', () => { + const text = "%put 'hello';\n%put 'world';\r\n" + const config = new LintConfig({ lineEndings: LineEndings.LF }) + expect(lineEndings.test(text, config)).toEqual([ + { + message: 'Incorrect line ending - CRLF instead of LF', + lineNumber: 2, + startColumnNumber: 13, + endColumnNumber: 14, + severity: Severity.Warning + } + ]) + }) + + it('should return an array with a single diagnostic when a line is terminated with an LF ending', () => { + const text = "%put 'hello';\n%put 'world';\r\n" + const config = new LintConfig({ lineEndings: LineEndings.CRLF }) + expect(lineEndings.test(text, config)).toEqual([ + { + message: 'Incorrect line ending - LF instead of CRLF', + lineNumber: 1, + startColumnNumber: 13, + endColumnNumber: 14, + severity: Severity.Warning + } + ]) + }) + + it('should return an array with a diagnostic for each line terminated with an LF ending', () => { + const text = "%put 'hello';\n%put 'test';\r\n%put 'world';\n" + const config = new LintConfig({ lineEndings: LineEndings.CRLF }) + expect(lineEndings.test(text, config)).toContainEqual({ + message: 'Incorrect line ending - LF instead of CRLF', + lineNumber: 1, + startColumnNumber: 13, + endColumnNumber: 14, + severity: Severity.Warning + }) + expect(lineEndings.test(text, config)).toContainEqual({ + message: 'Incorrect line ending - LF instead of CRLF', + lineNumber: 3, + startColumnNumber: 13, + endColumnNumber: 14, + severity: Severity.Warning + }) + }) + + it('should return an array with a diagnostic for each line terminated with a CRLF ending', () => { + const text = "%put 'hello';\r\n%put 'test';\n%put 'world';\r\n" + const config = new LintConfig({ lineEndings: LineEndings.LF }) + expect(lineEndings.test(text, config)).toContainEqual({ + message: 'Incorrect line ending - CRLF instead of LF', + lineNumber: 1, + startColumnNumber: 13, + endColumnNumber: 14, + severity: Severity.Warning + }) + expect(lineEndings.test(text, config)).toContainEqual({ + message: 'Incorrect line ending - CRLF instead of LF', + lineNumber: 3, + startColumnNumber: 13, + endColumnNumber: 14, + severity: Severity.Warning + }) + }) + + it('should return an array with a diagnostic for lines terminated with a CRLF ending', () => { + const text = + "%put 'hello';\r\n%put 'test';\r\n%put 'world';\n%put 'test2';\n%put 'world2';\r\n" + const config = new LintConfig({ lineEndings: LineEndings.LF }) + expect(lineEndings.test(text, config)).toContainEqual({ + message: 'Incorrect line ending - CRLF instead of LF', + lineNumber: 1, + startColumnNumber: 13, + endColumnNumber: 14, + severity: Severity.Warning + }) + expect(lineEndings.test(text, config)).toContainEqual({ + message: 'Incorrect line ending - CRLF instead of LF', + lineNumber: 2, + startColumnNumber: 12, + endColumnNumber: 13, + severity: Severity.Warning + }) + expect(lineEndings.test(text, config)).toContainEqual({ + message: 'Incorrect line ending - CRLF instead of LF', + lineNumber: 5, + startColumnNumber: 14, + endColumnNumber: 15, + severity: Severity.Warning + }) + }) + + it('should transform line endings to LF', () => { + const text = + "%put 'hello';\r\n%put 'test';\r\n%put 'world';\n%put 'test2';\n%put 'world2';\r\n" + const config = new LintConfig({ lineEndings: LineEndings.LF }) + + const formattedText = lineEndings.fix!(text, config) + + expect(formattedText).toEqual( + "%put 'hello';\n%put 'test';\n%put 'world';\n%put 'test2';\n%put 'world2';\n" + ) + }) + + it('should transform line endings to CRLF', () => { + const text = + "%put 'hello';\r\n%put 'test';\r\n%put 'world';\n%put 'test2';\n%put 'world2';\r\n" + const config = new LintConfig({ lineEndings: LineEndings.CRLF }) + + const formattedText = lineEndings.fix!(text, config) + + expect(formattedText).toEqual( + "%put 'hello';\r\n%put 'test';\r\n%put 'world';\r\n%put 'test2';\r\n%put 'world2';\r\n" + ) + }) + + it('should use LF line endings by default', () => { + const text = + "%put 'hello';\r\n%put 'test';\r\n%put 'world';\n%put 'test2';\n%put 'world2';\r\n" + + const formattedText = lineEndings.fix!(text) + + expect(formattedText).toEqual( + "%put 'hello';\n%put 'test';\n%put 'world';\n%put 'test2';\n%put 'world2';\n" + ) + }) +}) diff --git a/src/rules/file/lineEndings.ts b/src/rules/file/lineEndings.ts new file mode 100644 index 0000000..77cf17a --- /dev/null +++ b/src/rules/file/lineEndings.ts @@ -0,0 +1,83 @@ +import { Diagnostic, LintConfig } from '../../types' +import { LineEndings } from '../../types/LineEndings' +import { FileLintRule } from '../../types/LintRule' +import { LintRuleType } from '../../types/LintRuleType' +import { Severity } from '../../types/Severity' + +const name = 'lineEndings' +const description = 'Ensures line endings conform to the configured type.' +const message = 'Incorrect line ending - {actual} instead of {expected}' +const test = (value: string, config?: LintConfig) => { + const lineEndingConfig = config?.lineEndings || LineEndings.LF + const expectedLineEnding = + lineEndingConfig === LineEndings.LF ? '{lf}' : '{crlf}' + const incorrectLineEnding = expectedLineEnding === '{lf}' ? '{crlf}' : '{lf}' + + const lines = value + .replace(/\r\n/g, '{crlf}') + .replace(/\n/g, '{lf}') + .split(new RegExp(`(?<=${expectedLineEnding})`)) + const diagnostics: Diagnostic[] = [] + + let indexOffset = 0 + lines.forEach((line, index) => { + if (line.endsWith(incorrectLineEnding)) { + diagnostics.push({ + message: message + .replace('{expected}', expectedLineEnding === '{lf}' ? 'LF' : 'CRLF') + .replace('{actual}', incorrectLineEnding === '{lf}' ? 'LF' : 'CRLF'), + lineNumber: index + 1 + indexOffset, + startColumnNumber: line.indexOf(incorrectLineEnding), + endColumnNumber: line.indexOf(incorrectLineEnding) + 1, + severity: Severity.Warning + }) + } else { + const splitLine = line.split(new RegExp(`(?<=${incorrectLineEnding})`)) + if (splitLine.length > 1) { + indexOffset += splitLine.length - 1 + } + splitLine.forEach((l, i) => { + if (l.endsWith(incorrectLineEnding)) { + diagnostics.push({ + message: message + .replace( + '{expected}', + expectedLineEnding === '{lf}' ? 'LF' : 'CRLF' + ) + .replace( + '{actual}', + incorrectLineEnding === '{lf}' ? 'LF' : 'CRLF' + ), + lineNumber: index + i + 1, + startColumnNumber: l.indexOf(incorrectLineEnding), + endColumnNumber: l.indexOf(incorrectLineEnding) + 1, + severity: Severity.Warning + }) + } + }) + } + }) + return diagnostics +} + +const fix = (value: string, config?: LintConfig): string => { + const lineEndingConfig = config?.lineEndings || LineEndings.LF + + return value + .replace(/\r\n/g, '{crlf}') + .replace(/\n/g, '{lf}') + .replace(/{crlf}/g, lineEndingConfig === LineEndings.LF ? '\n' : '\r\n') + .replace(/{lf}/g, lineEndingConfig === LineEndings.LF ? '\n' : '\r\n') +} + +/** + * Lint rule that checks if line endings in a file match the configured type. + */ +export const lineEndings: FileLintRule = { + type: LintRuleType.File, + name, + description, + message, + test, + fix +} diff --git a/src/rules/file/noNestedMacros.spec.ts b/src/rules/file/noNestedMacros.spec.ts index b9ad5c9..1daccc4 100644 --- a/src/rules/file/noNestedMacros.spec.ts +++ b/src/rules/file/noNestedMacros.spec.ts @@ -1,3 +1,4 @@ +import { LintConfig } from '../../types' import { Severity } from '../../types/Severity' import { noNestedMacros } from './noNestedMacros' @@ -29,13 +30,13 @@ describe('noNestedMacros', () => { message: "Macro definition for 'inner' present in macro 'outer'", lineNumber: 4, startColumnNumber: 7, - endColumnNumber: 20, + endColumnNumber: 21, severity: Severity.Warning } ]) }) - it('should return an array with a single diagnostic when nested macros are defined at 2 levels', () => { + it('should return an array with two diagnostics when nested macros are defined at 2 levels', () => { const content = ` %macro outer(); /* any amount of arbitrary code */ @@ -52,22 +53,20 @@ describe('noNestedMacros', () => { %outer()` - expect(noNestedMacros.test(content)).toEqual([ - { - message: "Macro definition for 'inner' present in macro 'outer'", - lineNumber: 4, - startColumnNumber: 7, - endColumnNumber: 20, - severity: Severity.Warning - }, - { - message: "Macro definition for 'inner2' present in macro 'inner'", - lineNumber: 7, - startColumnNumber: 17, - endColumnNumber: 31, - severity: Severity.Warning - } - ]) + expect(noNestedMacros.test(content)).toContainEqual({ + message: "Macro definition for 'inner' present in macro 'outer'", + lineNumber: 4, + startColumnNumber: 7, + endColumnNumber: 21, + severity: Severity.Warning + }) + expect(noNestedMacros.test(content)).toContainEqual({ + message: "Macro definition for 'inner2' present in macro 'inner'", + lineNumber: 7, + startColumnNumber: 17, + endColumnNumber: 32, + severity: Severity.Warning + }) }) it('should return an empty array when the file is undefined', () => { @@ -75,4 +74,23 @@ describe('noNestedMacros', () => { expect(noNestedMacros.test((content as unknown) as string)).toEqual([]) }) + + it('should use the configured line ending while testing content', () => { + const content = `%macro outer();\r\n%macro inner;\r\n%mend inner;\r\n%mend outer;` + + const diagnostics = noNestedMacros.test( + content, + new LintConfig({ lineEndings: 'crlf' }) + ) + + expect(diagnostics).toEqual([ + { + message: "Macro definition for 'inner' present in macro 'outer'", + lineNumber: 2, + startColumnNumber: 1, + endColumnNumber: 13, + severity: Severity.Warning + } + ]) + }) }) diff --git a/src/rules/file/noNestedMacros.ts b/src/rules/file/noNestedMacros.ts index dca0802..338ae15 100644 --- a/src/rules/file/noNestedMacros.ts +++ b/src/rules/file/noNestedMacros.ts @@ -2,57 +2,41 @@ import { Diagnostic } from '../../types/Diagnostic' import { FileLintRule } from '../../types/LintRule' import { LintRuleType } from '../../types/LintRuleType' import { Severity } from '../../types/Severity' -import { trimComments } from '../../utils/trimComments' import { getColumnNumber } from '../../utils/getColumnNumber' +import { parseMacros } from '../../utils/parseMacros' +import { LintConfig } from '../../types' +import { LineEndings } from '../../types/LineEndings' const name = 'noNestedMacros' const description = 'Enfoces the absence of nested macro definitions.' const message = `Macro definition for '{macro}' present in macro '{parent}'` -const test = (value: string) => { +const test = (value: string, config?: LintConfig) => { + const lineEnding = config?.lineEndings === LineEndings.CRLF ? '\r\n' : '\n' + const lines: string[] = value ? value.split(lineEnding) : [] const diagnostics: Diagnostic[] = [] - const declaredMacros: string[] = [] - - const lines: string[] = value ? value.split('\n') : [] - let isCommentStarted = false - lines.forEach((line, lineIndex) => { - const { statement: trimmedLine, commentStarted } = trimComments( - line, - isCommentStarted - ) - isCommentStarted = commentStarted - const statements: string[] = trimmedLine ? trimmedLine.split(';') : [] - - statements.forEach((statement) => { - const { statement: trimmedStatement, commentStarted } = trimComments( - statement, - isCommentStarted - ) - isCommentStarted = commentStarted - - if (trimmedStatement.startsWith('%macro ')) { - const macroName = trimmedStatement - .slice(7, trimmedStatement.length) - .trim() - .split('(')[0] - if (declaredMacros.length) { - const parentMacro = declaredMacros.slice(-1).pop() - diagnostics.push({ - message: message - .replace('{macro}', macroName) - .replace('{parent}', parentMacro!), - lineNumber: lineIndex + 1, - startColumnNumber: getColumnNumber(line, '%macro'), - endColumnNumber: - getColumnNumber(line, '%macro') + trimmedStatement.length - 1, - severity: Severity.Warning - }) - } - declaredMacros.push(macroName) - } else if (trimmedStatement.startsWith('%mend')) { - declaredMacros.pop() - } + const macros = parseMacros(value, config) + macros + .filter((m) => !!m.parentMacro) + .forEach((macro) => { + diagnostics.push({ + message: message + .replace('{macro}', macro.name) + .replace('{parent}', macro.parentMacro), + lineNumber: macro.startLineNumber as number, + startColumnNumber: getColumnNumber( + lines[(macro.startLineNumber as number) - 1], + '%macro' + ), + endColumnNumber: + getColumnNumber( + lines[(macro.startLineNumber as number) - 1], + '%macro' + ) + + lines[(macro.startLineNumber as number) - 1].trim().length - + 1, + severity: Severity.Warning + }) }) - }) return diagnostics } diff --git a/src/rules/line/noTrailingSpaces.ts b/src/rules/line/noTrailingSpaces.ts index 0fe4bc1..2200a87 100644 --- a/src/rules/line/noTrailingSpaces.ts +++ b/src/rules/line/noTrailingSpaces.ts @@ -17,6 +17,7 @@ const test = (value: string, lineNumber: number) => severity: Severity.Warning } ] +const fix = (value: string) => value.trimEnd() /** * Lint rule that checks for the presence of trailing space(s) in a given line of text. @@ -26,5 +27,6 @@ export const noTrailingSpaces: LineLintRule = { name, description, message, - test + test, + fix } diff --git a/src/types/LineEndings.ts b/src/types/LineEndings.ts new file mode 100644 index 0000000..e40f19b --- /dev/null +++ b/src/types/LineEndings.ts @@ -0,0 +1,4 @@ +export enum LineEndings { + LF = 'lf', + CRLF = 'crlf' +} diff --git a/src/types/LintConfig.spec.ts b/src/types/LintConfig.spec.ts index 3ea3bb5..67ac80f 100644 --- a/src/types/LintConfig.spec.ts +++ b/src/types/LintConfig.spec.ts @@ -1,3 +1,4 @@ +import { LineEndings } from './LineEndings' import { LintConfig } from './LintConfig' import { LintRuleType } from './LintRuleType' @@ -108,6 +109,33 @@ describe('LintConfig', () => { expect(config.indentationMultiple).toEqual(0) }) + it('should create an instance with the line endings set to LF', () => { + const config = new LintConfig({ lineEndings: 'lf' }) + + expect(config).toBeTruthy() + expect(config.lineEndings).toEqual(LineEndings.LF) + }) + + it('should create an instance with the line endings set to CRLF', () => { + const config = new LintConfig({ lineEndings: 'crlf' }) + + expect(config).toBeTruthy() + expect(config.lineEndings).toEqual(LineEndings.CRLF) + }) + + it('should create an instance with the line endings set to LF by default', () => { + const config = new LintConfig({}) + + expect(config).toBeTruthy() + expect(config.lineEndings).toEqual(LineEndings.LF) + }) + + it('should throw an error with an invalid value for line endings', () => { + expect(() => new LintConfig({ lineEndings: 'test' })).toThrowError( + `Invalid value for lineEndings: can be ${LineEndings.LF} or ${LineEndings.CRLF}` + ) + }) + it('should create an instance with all flags set', () => { const config = new LintConfig({ noTrailingSpaces: true, diff --git a/src/types/LintConfig.ts b/src/types/LintConfig.ts index 3dea4b1..d7f2f88 100644 --- a/src/types/LintConfig.ts +++ b/src/types/LintConfig.ts @@ -2,7 +2,8 @@ import { hasDoxygenHeader, hasMacroNameInMend, noNestedMacros, - hasMacroParentheses + hasMacroParentheses, + lineEndings } from '../rules/file' import { indentationMultiple, @@ -12,6 +13,7 @@ import { noTrailingSpaces } from '../rules/line' import { lowerCaseFileNames, noSpacesInFileNames } from '../rules/path' +import { LineEndings } from './LineEndings' import { FileLintRule, LineLintRule, PathLintRule } from './LintRule' /** @@ -27,6 +29,7 @@ export class LintConfig { readonly pathLintRules: PathLintRule[] = [] readonly maxLineLength: number = 80 readonly indentationMultiple: number = 2 + readonly lineEndings: LineEndings = LineEndings.LF constructor(json?: any) { if (json?.noTrailingSpaces) { @@ -46,6 +49,19 @@ export class LintConfig { this.lineLintRules.push(maxLineLength) } + if (json?.lineEndings) { + if ( + json.lineEndings !== LineEndings.LF && + json.lineEndings !== LineEndings.CRLF + ) { + throw new Error( + `Invalid value for lineEndings: can be ${LineEndings.LF} or ${LineEndings.CRLF}` + ) + } + this.lineEndings = json.lineEndings + this.fileLintRules.push(lineEndings) + } + if (!isNaN(json?.indentationMultiple)) { this.indentationMultiple = json.indentationMultiple as number this.lineLintRules.push(indentationMultiple) diff --git a/src/types/LintRule.ts b/src/types/LintRule.ts index d3fbf29..f32a58c 100644 --- a/src/types/LintRule.ts +++ b/src/types/LintRule.ts @@ -19,6 +19,7 @@ export interface LintRule { export interface LineLintRule extends LintRule { type: LintRuleType.Line test: (value: string, lineNumber: number, config?: LintConfig) => Diagnostic[] + fix?: (value: string, config?: LintConfig) => string } /** @@ -26,7 +27,8 @@ export interface LineLintRule extends LintRule { */ export interface FileLintRule extends LintRule { type: LintRuleType.File - test: (value: string) => Diagnostic[] + test: (value: string, config?: LintConfig) => Diagnostic[] + fix?: (value: string, config?: LintConfig) => string } /** diff --git a/src/utils/index.ts b/src/utils/index.ts index f48b820..b0a151b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './getLintConfig' export * from './getProjectRoot' export * from './listSasFiles' +export * from './splitText' diff --git a/src/utils/parseMacros.spec.ts b/src/utils/parseMacros.spec.ts new file mode 100644 index 0000000..1367523 --- /dev/null +++ b/src/utils/parseMacros.spec.ts @@ -0,0 +1,95 @@ +import { LintConfig } from '../types' +import { parseMacros } from './parseMacros' + +describe('parseMacros', () => { + it('should return an array with a single macro', () => { + const text = `%macro test; + %put 'hello'; +%mend` + + const macros = parseMacros(text, new LintConfig()) + + expect(macros.length).toEqual(1) + expect(macros).toContainEqual({ + name: 'test', + declaration: '%macro test;', + termination: '%mend', + startLineNumber: 1, + endLineNumber: 3, + 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 macros = parseMacros(text, new LintConfig()) + + expect(macros.length).toEqual(2) + expect(macros).toContainEqual({ + name: 'foo', + declaration: '%macro foo;', + termination: '%mend;', + startLineNumber: 1, + endLineNumber: 3, + parentMacro: '', + hasMacroNameInMend: false, + hasParentheses: false, + mismatchedMendMacroName: '' + }) + expect(macros).toContainEqual({ + name: 'bar', + declaration: '%macro bar();', + termination: '%mend bar;', + startLineNumber: 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 macros = parseMacros(text, new LintConfig()) + + expect(macros.length).toEqual(2) + expect(macros).toContainEqual({ + name: 'test', + declaration: '%macro test()', + termination: '%mend test', + startLineNumber: 1, + endLineNumber: 6, + parentMacro: '', + hasMacroNameInMend: true, + hasParentheses: true, + mismatchedMendMacroName: '' + }) + expect(macros).toContainEqual({ + name: 'test2', + declaration: ' %macro test2', + termination: ' %mend', + startLineNumber: 3, + endLineNumber: 5, + parentMacro: 'test', + hasMacroNameInMend: false, + hasParentheses: false, + mismatchedMendMacroName: '' + }) + }) +}) diff --git a/src/utils/parseMacros.ts b/src/utils/parseMacros.ts new file mode 100644 index 0000000..65ecd24 --- /dev/null +++ b/src/utils/parseMacros.ts @@ -0,0 +1,90 @@ +import { LintConfig } from '../types/LintConfig' +import { LineEndings } from '../types/LineEndings' +import { trimComments } from './trimComments' + +interface Macro { + name: string + startLineNumber: number | null + endLineNumber: number | null + 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) : [] + const macros: Macro[] = [] + + let isCommentStarted = false + let macroStack: Macro[] = [] + lines.forEach((line, index) => { + const { statement: trimmedLine, commentStarted } = trimComments( + line, + isCommentStarted + ) + isCommentStarted = commentStarted + const statements: string[] = trimmedLine ? trimmedLine.split(';') : [] + + statements.forEach((statement) => { + const { statement: trimmedStatement, commentStarted } = trimComments( + statement, + isCommentStarted + ) + isCommentStarted = commentStarted + + if (trimmedStatement.startsWith('%macro')) { + const startLineNumber = index + 1 + const name = trimmedStatement + .slice(7, trimmedStatement.length) + .trim() + .split('(')[0] + macroStack.push({ + name, + startLineNumber, + endLineNumber: null, + parentMacro: macroStack.length + ? macroStack[macroStack.length - 1].name + : '', + hasParentheses: trimmedStatement.endsWith('()'), + hasMacroNameInMend: false, + mismatchedMendMacroName: '', + declaration: line, + termination: '' + }) + } else if (trimmedStatement.startsWith('%mend')) { + if (macroStack.length) { + const macro = macroStack.pop() as Macro + const mendMacroName = + trimmedStatement.split(' ').filter((s: string) => !!s)[1] || '' + macro.endLineNumber = index + 1 + macro.hasMacroNameInMend = trimmedStatement.includes(macro.name) + macro.mismatchedMendMacroName = macro.hasMacroNameInMend + ? '' + : mendMacroName + macro.termination = line + macros.push(macro) + } else { + macros.push({ + name: '', + startLineNumber: null, + endLineNumber: index + 1, + parentMacro: '', + hasParentheses: false, + hasMacroNameInMend: false, + mismatchedMendMacroName: '', + declaration: '', + termination: line + }) + } + } + }) + }) + + macros.push(...macroStack) + + return macros +} diff --git a/src/utils/splitText.spec.ts b/src/utils/splitText.spec.ts new file mode 100644 index 0000000..b6a2531 --- /dev/null +++ b/src/utils/splitText.spec.ts @@ -0,0 +1,41 @@ +import { LintConfig } from '../types' +import { splitText } from './splitText' + +describe('splitText', () => { + const config = new LintConfig({ + noTrailingSpaces: true, + noEncodedPasswords: true, + hasDoxygenHeader: true, + noSpacesInFileNames: true, + maxLineLength: 80, + lowerCaseFileNames: true, + noTabIndentation: true, + indentationMultiple: 2, + hasMacroNameInMend: true, + noNestedMacros: true, + hasMacroParentheses: true, + lineEndings: 'lf' + }) + + it('should return an empty array when text is falsy', () => { + const lines = splitText('', config) + + expect(lines.length).toEqual(0) + }) + + it('should return an array of lines from text', () => { + const lines = splitText(`line 1\nline 2`, config) + + expect(lines.length).toEqual(2) + expect(lines[0]).toEqual('line 1') + expect(lines[1]).toEqual('line 2') + }) + + it('should work with CRLF line endings', () => { + const lines = splitText(`line 1\r\nline 2`, config) + + expect(lines.length).toEqual(2) + expect(lines[0]).toEqual('line 1') + expect(lines[1]).toEqual('line 2') + }) +}) diff --git a/src/utils/splitText.ts b/src/utils/splitText.ts new file mode 100644 index 0000000..498230e --- /dev/null +++ b/src/utils/splitText.ts @@ -0,0 +1,17 @@ +import { LintConfig } from '../types/LintConfig' +import { LineEndings } from '../types/LineEndings' + +/** + * Splits the given content into a list of lines, regardless of CRLF or LF line endings. + * @param {string} text - the text content to be split into lines. + * @returns {string[]} an array of lines from the given text + */ +export const splitText = (text: string, config: LintConfig): string[] => { + if (!text) return [] + const expectedLineEndings = + config.lineEndings === LineEndings.LF ? '\n' : '\r\n' + const incorrectLineEndings = expectedLineEndings === '\n' ? '\r\n' : '\n' + return text + .replace(new RegExp(incorrectLineEndings, 'g'), expectedLineEndings) + .split(expectedLineEndings) +} From d28d32d441c0a3885ec431d0ab9a073ed6c8f621 Mon Sep 17 00:00:00 2001 From: Krishna Acondy Date: Mon, 19 Apr 2021 21:07:24 +0100 Subject: [PATCH 02/12] fix(*): add SAS Macros section to Doxygen header --- jest.config.js | 2 +- src/format/formatText.spec.ts | 3 ++- src/rules/file/hasDoxygenHeader.spec.ts | 5 ++++- src/rules/file/hasDoxygenHeader.ts | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/jest.config.js b/jest.config.js index cecb39e..018b0ea 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,5 +9,5 @@ module.exports = { statements: -10 } }, - collectCoverageFrom: ['src/**/{!(index|example),}.ts'] + collectCoverageFrom: ['src/**/{!(index|formatExample|lintExample),}.ts'] } diff --git a/src/format/formatText.spec.ts b/src/format/formatText.spec.ts index 0263887..03b097b 100644 --- a/src/format/formatText.spec.ts +++ b/src/format/formatText.spec.ts @@ -18,6 +18,7 @@ describe('formatText', () => { const expectedOutput = `/** @file @brief +

SAS Macros

**/\n%macro test %put 'hello';\n%mend test;\n` @@ -39,7 +40,7 @@ describe('formatText', () => { ) const text = `%macro test\n %put 'hello';\r\n%mend; ` - const expectedOutput = `/**\r\n @file\r\n @brief \r\n**/\r\n%macro test\r\n %put 'hello';\r\n%mend test;\r\n` + 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;\r\n` const output = await formatText(text) diff --git a/src/rules/file/hasDoxygenHeader.spec.ts b/src/rules/file/hasDoxygenHeader.spec.ts index 9f66a73..5cd2e2d 100644 --- a/src/rules/file/hasDoxygenHeader.spec.ts +++ b/src/rules/file/hasDoxygenHeader.spec.ts @@ -94,6 +94,7 @@ describe('hasDoxygenHeader', () => { `/** @file @brief +

SAS Macros

**/` + '\n' + content @@ -105,7 +106,9 @@ describe('hasDoxygenHeader', () => { const config = new LintConfig({ lineEndings: 'crlf' }) expect(hasDoxygenHeader.fix!(content, config)).toEqual( - `/**\r\n @file\r\n @brief \r\n**/` + '\r\n' + content + `/**\r\n @file\r\n @brief \r\n

SAS Macros

\r\n**/` + + '\r\n' + + content ) }) }) diff --git a/src/rules/file/hasDoxygenHeader.ts b/src/rules/file/hasDoxygenHeader.ts index b6635e6..2a5c75b 100644 --- a/src/rules/file/hasDoxygenHeader.ts +++ b/src/rules/file/hasDoxygenHeader.ts @@ -4,7 +4,7 @@ import { FileLintRule } from '../../types/LintRule' import { LintRuleType } from '../../types/LintRuleType' import { Severity } from '../../types/Severity' -const DoxygenHeader = `/**{lineEnding} @file{lineEnding} @brief {lineEnding}**/` +const DoxygenHeader = `/**{lineEnding} @file{lineEnding} @brief {lineEnding}

SAS Macros

{lineEnding}**/` const name = 'hasDoxygenHeader' const description = From bcb50b9968e64ced24298d0c91a5fdefdae7160b Mon Sep 17 00:00:00 2001 From: Krishna Acondy Date: Mon, 19 Apr 2021 22:13:53 +0100 Subject: [PATCH 03/12] feat(format): add the ability to format files, folders and projects --- src/format/formatFile.spec.ts | 42 +++++++++++++++++++++++ src/format/formatFile.ts | 22 ++++++++++++ src/format/formatFolder.spec.ts | 59 ++++++++++++++++++++++++++++++++ src/format/formatFolder.ts | 42 +++++++++++++++++++++++ src/format/formatProject.spec.ts | 51 +++++++++++++++++++++++++++ src/format/formatProject.ts | 15 ++++++++ src/format/index.ts | 4 +++ src/formatExample.ts | 28 ++++++++------- src/index.ts | 1 + src/lint/lintFolder.spec.ts | 48 ++++++++++++++++++++++++-- src/lint/lintProject.spec.ts | 23 +++++++++++-- 11 files changed, 319 insertions(+), 16 deletions(-) create mode 100644 src/format/formatFile.spec.ts create mode 100644 src/format/formatFile.ts create mode 100644 src/format/formatFolder.spec.ts create mode 100644 src/format/formatFolder.ts create mode 100644 src/format/formatProject.spec.ts create mode 100644 src/format/formatProject.ts create mode 100644 src/format/index.ts diff --git a/src/format/formatFile.spec.ts b/src/format/formatFile.spec.ts new file mode 100644 index 0000000..d4ba1b5 --- /dev/null +++ b/src/format/formatFile.spec.ts @@ -0,0 +1,42 @@ +import { formatFile } from './formatFile' +import path from 'path' +import { createFile, deleteFile, readFile } from '@sasjs/utils/file' +import { LintConfig } from '../types' + +describe('formatFile', () => { + it('should fix linting issues in a given file', async () => { + const content = `%macro somemacro(); \n%put 'hello';\n%mend;` + const expectedContent = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;\n` + await createFile(path.join(__dirname, 'format-file-test.sas'), content) + + await formatFile(path.join(__dirname, 'format-file-test.sas')) + const result = await readFile(path.join(__dirname, 'format-file-test.sas')) + + expect(result).toEqual(expectedContent) + + await deleteFile(path.join(__dirname, 'format-file-test.sas')) + }) + + it('should use the provided config if available', async () => { + const content = `%macro somemacro(); \n%put 'hello';\n%mend;` + const expectedContent = `/**\r\n @file\r\n @brief \r\n

SAS Macros

\r\n**/\r\n%macro somemacro();\r\n%put 'hello';\r\n%mend somemacro;\r\n` + await createFile(path.join(__dirname, 'format-file-config.sas'), content) + + await formatFile( + path.join(__dirname, 'format-file-config.sas'), + new LintConfig({ + lineEndings: 'crlf', + hasMacroNameInMend: true, + hasDoxygenHeader: true, + noTrailingSpaces: true + }) + ) + const result = await readFile( + path.join(__dirname, 'format-file-config.sas') + ) + + expect(result).toEqual(expectedContent) + + await deleteFile(path.join(__dirname, 'format-file-config.sas')) + }) +}) diff --git a/src/format/formatFile.ts b/src/format/formatFile.ts new file mode 100644 index 0000000..fa6950f --- /dev/null +++ b/src/format/formatFile.ts @@ -0,0 +1,22 @@ +import { createFile, readFile } from '@sasjs/utils/file' +import { LintConfig } from '../types/LintConfig' +import { getLintConfig } from '../utils/getLintConfig' +import { processText } from './shared' + +/** + * Applies automatic formatting to the file at the given path. + * @param {string} filePath - the path to the file to be formatted. + * @param {LintConfig} configuration - an optional configuration. When not passed in, this is read from the .sasjslint file. + * @returns {Promise} Resolves successfully when the file has been formatted. + */ +export const formatFile = async ( + filePath: string, + configuration?: LintConfig +) => { + const config = configuration || (await getLintConfig()) + const text = await readFile(filePath) + + const formattedText = processText(text, config) + + await createFile(filePath, formattedText) +} diff --git a/src/format/formatFolder.spec.ts b/src/format/formatFolder.spec.ts new file mode 100644 index 0000000..5bda372 --- /dev/null +++ b/src/format/formatFolder.spec.ts @@ -0,0 +1,59 @@ +import { formatFolder } from './formatFolder' +import path from 'path' +import { + createFile, + createFolder, + deleteFolder, + readFile +} from '@sasjs/utils/file' + +describe('formatFolder', () => { + it('should fix linting issues in a given folder', async () => { + const content = `%macro somemacro(); \n%put 'hello';\n%mend;` + const expectedContent = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;\n` + await createFolder(path.join(__dirname, 'format-folder-test')) + await createFile( + path.join(__dirname, 'format-folder-test', 'format-folder-test.sas'), + content + ) + + await formatFolder(path.join(__dirname, 'format-folder-test')) + const result = await readFile( + path.join(__dirname, 'format-folder-test', 'format-folder-test.sas') + ) + + expect(result).toEqual(expectedContent) + + await deleteFolder(path.join(__dirname, 'format-folder-test')) + }) + + it('should fix linting issues in subfolders of a given folder', async () => { + const content = `%macro somemacro(); \n%put 'hello';\n%mend;` + const expectedContent = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;\n` + await createFolder(path.join(__dirname, 'format-folder-test')) + await createFolder(path.join(__dirname, 'subfolder')) + await createFile( + path.join( + __dirname, + 'format-folder-test', + 'subfolder', + 'format-folder-test.sas' + ), + content + ) + + await formatFolder(path.join(__dirname, 'format-folder-test')) + const result = await readFile( + path.join( + __dirname, + 'format-folder-test', + 'subfolder', + 'format-folder-test.sas' + ) + ) + + expect(result).toEqual(expectedContent) + + await deleteFolder(path.join(__dirname, 'format-folder-test')) + }) +}) diff --git a/src/format/formatFolder.ts b/src/format/formatFolder.ts new file mode 100644 index 0000000..b7ec86f --- /dev/null +++ b/src/format/formatFolder.ts @@ -0,0 +1,42 @@ +import { listSubFoldersInFolder } from '@sasjs/utils/file' +import path from 'path' +import { LintConfig } from '../types/LintConfig' +import { asyncForEach } from '../utils/asyncForEach' +import { getLintConfig } from '../utils/getLintConfig' +import { listSasFiles } from '../utils/listSasFiles' +import { formatFile } from './formatFile' + +const excludeFolders = [ + '.git', + '.github', + '.vscode', + 'node_modules', + 'sasjsbuild', + 'sasjsresults' +] + +/** + * Automatically formats all SAS files in the folder at the given path. + * @param {string} folderPath - the path to the folder to be formatted. + * @param {LintConfig} configuration - an optional configuration. When not passed in, this is read from the .sasjslint file. + * @returns {Promise} Resolves successfully when all SAS files in the given folder have been formatted. + */ +export const formatFolder = async ( + folderPath: string, + configuration?: LintConfig +) => { + const config = configuration || (await getLintConfig()) + const fileNames = await listSasFiles(folderPath) + await asyncForEach(fileNames, async (fileName) => { + const filePath = path.join(folderPath, fileName) + await formatFile(filePath) + }) + + const subFolders = (await listSubFoldersInFolder(folderPath)).filter( + (f: string) => !excludeFolders.includes(f) + ) + + await asyncForEach(subFolders, async (subFolder) => { + await formatFolder(path.join(folderPath, subFolder), config) + }) +} diff --git a/src/format/formatProject.spec.ts b/src/format/formatProject.spec.ts new file mode 100644 index 0000000..dc275eb --- /dev/null +++ b/src/format/formatProject.spec.ts @@ -0,0 +1,51 @@ +import { formatProject } from './formatProject' +import path from 'path' +import { + createFile, + createFolder, + deleteFolder, + readFile +} from '@sasjs/utils/file' +import { DefaultLintConfiguration } from '../utils' +import * as getProjectRootModule from '../utils/getProjectRoot' +jest.mock('../utils/getProjectRoot') + +describe('formatProject', () => { + it('should format files in the current project', async () => { + const content = `%macro somemacro(); \n%put 'hello';\n%mend;` + const expectedContent = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;\n` + await createFolder(path.join(__dirname, 'format-project-test')) + await createFile( + path.join(__dirname, 'format-project-test', 'format-project-test.sas'), + content + ) + await createFile( + path.join(__dirname, 'format-project-test', '.sasjslint'), + JSON.stringify(DefaultLintConfiguration) + ) + jest + .spyOn(getProjectRootModule, 'getProjectRoot') + .mockImplementation(() => + Promise.resolve(path.join(__dirname, 'format-project-test')) + ) + + await formatProject() + const result = await readFile( + path.join(__dirname, 'format-project-test', 'format-project-test.sas') + ) + + expect(result).toEqual(expectedContent) + + await deleteFolder(path.join(__dirname, 'format-project-test')) + }) + + it('should throw an error when a project root is not found', async () => { + jest + .spyOn(getProjectRootModule, 'getProjectRoot') + .mockImplementationOnce(() => Promise.resolve('')) + + await expect(formatProject()).rejects.toThrowError( + 'SASjs Project Root was not found.' + ) + }) +}) diff --git a/src/format/formatProject.ts b/src/format/formatProject.ts new file mode 100644 index 0000000..c0eb2fd --- /dev/null +++ b/src/format/formatProject.ts @@ -0,0 +1,15 @@ +import { getProjectRoot } from '../utils/getProjectRoot' +import { formatFolder } from './formatFolder' + +/** + * Automatically formats all SAS files in the current project. + * @returns {Promise} Resolves successfully when all SAS files in the current project have been formatted. + */ +export const formatProject = async () => { + const projectRoot = + (await getProjectRoot()) || process.projectDir || process.currentDir + if (!projectRoot) { + throw new Error('SASjs Project Root was not found.') + } + return await formatFolder(projectRoot) +} diff --git a/src/format/index.ts b/src/format/index.ts new file mode 100644 index 0000000..5bff5f1 --- /dev/null +++ b/src/format/index.ts @@ -0,0 +1,4 @@ +export * from './formatText' +export * from './formatFile' +export * from './formatFolder' +export * from './formatProject' diff --git a/src/formatExample.ts b/src/formatExample.ts index 10a428c..816c608 100644 --- a/src/formatExample.ts +++ b/src/formatExample.ts @@ -1,3 +1,5 @@ +import { formatFile } from './format/formatFile' +import path from 'path' import { formatText } from './format/formatText' import { lintText } from './lint' @@ -7,15 +9,17 @@ const content = `%put 'Hello'; %put 'test'; %mend;\r\n` -console.log(content) -lintText(content).then((diagnostics) => { - console.log('Before Formatting:') - console.table(diagnostics) - formatText(content).then((formattedText) => { - lintText(formattedText).then((newDiagnostics) => { - console.log('After Formatting:') - console.log(formattedText) - console.table(newDiagnostics) - }) - }) -}) +// console.log(content) +// lintText(content).then((diagnostics) => { +// console.log('Before Formatting:') +// console.table(diagnostics) +// formatText(content).then((formattedText) => { +// lintText(formattedText).then((newDiagnostics) => { +// console.log('After Formatting:') +// console.log(formattedText) +// console.table(newDiagnostics) +// }) +// }) +// }) + +formatFile(path.join(__dirname, 'Example File.sas')) diff --git a/src/index.ts b/src/index.ts index 7ed1b17..160eb2c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from './format' export * from './lint' export * from './types' export * from './utils' diff --git a/src/lint/lintFolder.spec.ts b/src/lint/lintFolder.spec.ts index 69938fd..9c2c2c9 100644 --- a/src/lint/lintFolder.spec.ts +++ b/src/lint/lintFolder.spec.ts @@ -1,6 +1,12 @@ import { lintFolder } from './lintFolder' import { Severity } from '../types/Severity' import path from 'path' +import { + createFile, + createFolder, + deleteFolder, + readFile +} from '@sasjs/utils/file' const expectedFilesCount = 1 const expectedDiagnostics = [ @@ -71,11 +77,47 @@ const expectedDiagnostics = [ describe('lintFolder', () => { it('should identify lint issues in a given folder', async () => { - const results = await lintFolder(path.join(__dirname, '..')) - + await createFolder(path.join(__dirname, 'lint-folder-test')) + const content = await readFile( + path.join(__dirname, '..', 'Example File.sas') + ) + await createFile( + path.join(__dirname, 'lint-folder-test', 'Example File.sas'), + content + ) + const results = await lintFolder(path.join(__dirname, 'lint-folder-test')) expect(results.size).toEqual(expectedFilesCount) const diagnostics = results.get( + path.join(__dirname, 'lint-folder-test', 'Example File.sas') + )! + expect(diagnostics.length).toEqual(expectedDiagnostics.length) + expect(diagnostics).toContainEqual(expectedDiagnostics[0]) + expect(diagnostics).toContainEqual(expectedDiagnostics[1]) + expect(diagnostics).toContainEqual(expectedDiagnostics[2]) + expect(diagnostics).toContainEqual(expectedDiagnostics[3]) + expect(diagnostics).toContainEqual(expectedDiagnostics[4]) + expect(diagnostics).toContainEqual(expectedDiagnostics[5]) + expect(diagnostics).toContainEqual(expectedDiagnostics[6]) + expect(diagnostics).toContainEqual(expectedDiagnostics[7]) + expect(diagnostics).toContainEqual(expectedDiagnostics[8]) + + await deleteFolder(path.join(__dirname, 'lint-folder-test')) + }) + + it('should identify lint issues in subfolders of a given folder', async () => { + await createFolder(path.join(__dirname, 'lint-folder-test')) + await createFolder(path.join(__dirname, 'lint-folder-test', 'subfolder')) + const content = await readFile( path.join(__dirname, '..', 'Example File.sas') + ) + await createFile( + path.join(__dirname, 'lint-folder-test', 'subfolder', 'Example File.sas'), + content + ) + const results = await lintFolder(path.join(__dirname, 'lint-folder-test')) + expect(results.size).toEqual(expectedFilesCount) + const diagnostics = results.get( + path.join(__dirname, 'lint-folder-test', 'subfolder', 'Example File.sas') )! expect(diagnostics.length).toEqual(expectedDiagnostics.length) expect(diagnostics).toContainEqual(expectedDiagnostics[0]) @@ -87,5 +129,7 @@ describe('lintFolder', () => { expect(diagnostics).toContainEqual(expectedDiagnostics[6]) expect(diagnostics).toContainEqual(expectedDiagnostics[7]) expect(diagnostics).toContainEqual(expectedDiagnostics[8]) + + await deleteFolder(path.join(__dirname, 'lint-folder-test')) }) }) diff --git a/src/lint/lintProject.spec.ts b/src/lint/lintProject.spec.ts index ec7245f..31a3151 100644 --- a/src/lint/lintProject.spec.ts +++ b/src/lint/lintProject.spec.ts @@ -2,6 +2,8 @@ import { lintProject } from './lintProject' import { Severity } from '../types/Severity' import * as getProjectRootModule from '../utils/getProjectRoot' import path from 'path' +import { createFolder, createFile, readFile, deleteFolder } from '@sasjs/utils' +import { DefaultLintConfiguration } from '../utils' jest.mock('../utils/getProjectRoot') const expectedFilesCount = 1 @@ -73,14 +75,29 @@ const expectedDiagnostics = [ describe('lintProject', () => { it('should identify lint issues in a given project', async () => { + await createFolder(path.join(__dirname, 'lint-project-test')) + const content = await readFile( + path.join(__dirname, '..', 'Example File.sas') + ) + await createFile( + path.join(__dirname, 'lint-project-test', 'Example File.sas'), + content + ) + await createFile( + path.join(__dirname, 'lint-project-test', '.sasjslint'), + JSON.stringify(DefaultLintConfiguration) + ) + jest .spyOn(getProjectRootModule, 'getProjectRoot') - .mockImplementation(() => Promise.resolve(path.join(__dirname, '..'))) + .mockImplementation(() => + Promise.resolve(path.join(__dirname, 'lint-project-test')) + ) const results = await lintProject() expect(results.size).toEqual(expectedFilesCount) const diagnostics = results.get( - path.join(__dirname, '..', 'Example File.sas') + path.join(__dirname, 'lint-project-test', 'Example File.sas') )! expect(diagnostics.length).toEqual(expectedDiagnostics.length) expect(diagnostics).toContainEqual(expectedDiagnostics[0]) @@ -92,6 +109,8 @@ describe('lintProject', () => { expect(diagnostics).toContainEqual(expectedDiagnostics[6]) expect(diagnostics).toContainEqual(expectedDiagnostics[7]) expect(diagnostics).toContainEqual(expectedDiagnostics[8]) + + await deleteFolder(path.join(__dirname, 'lint-project-test')) }) it('should throw an error when a project root is not found', async () => { From 93124bec5b829d8d1d81da0bbb2acf30684a458c Mon Sep 17 00:00:00 2001 From: Krishna Acondy Date: Mon, 19 Apr 2021 22:15:11 +0100 Subject: [PATCH 04/12] chore(*): revert change to example file --- src/formatExample.ts | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/formatExample.ts b/src/formatExample.ts index 816c608..10a428c 100644 --- a/src/formatExample.ts +++ b/src/formatExample.ts @@ -1,5 +1,3 @@ -import { formatFile } from './format/formatFile' -import path from 'path' import { formatText } from './format/formatText' import { lintText } from './lint' @@ -9,17 +7,15 @@ const content = `%put 'Hello'; %put 'test'; %mend;\r\n` -// console.log(content) -// lintText(content).then((diagnostics) => { -// console.log('Before Formatting:') -// console.table(diagnostics) -// formatText(content).then((formattedText) => { -// lintText(formattedText).then((newDiagnostics) => { -// console.log('After Formatting:') -// console.log(formattedText) -// console.table(newDiagnostics) -// }) -// }) -// }) - -formatFile(path.join(__dirname, 'Example File.sas')) +console.log(content) +lintText(content).then((diagnostics) => { + console.log('Before Formatting:') + console.table(diagnostics) + formatText(content).then((formattedText) => { + lintText(formattedText).then((newDiagnostics) => { + console.log('After Formatting:') + console.log(formattedText) + console.table(newDiagnostics) + }) + }) +}) From 6fd941aa2d2985d5b63ceecf776a3766c27e288e Mon Sep 17 00:00:00 2001 From: Saad Jutt Date: Wed, 21 Apr 2021 03:27:24 +0500 Subject: [PATCH 05/12] tests(hasMacroNameInMend): Added more --- src/rules/file/hasMacroNameInMend.spec.ts | 28 ++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/rules/file/hasMacroNameInMend.spec.ts b/src/rules/file/hasMacroNameInMend.spec.ts index 7df322d..3dabc2a 100644 --- a/src/rules/file/hasMacroNameInMend.spec.ts +++ b/src/rules/file/hasMacroNameInMend.spec.ts @@ -339,10 +339,32 @@ describe('hasMacroNameInMend', () => { } ]) }) - it('should add macro name to the mend statement if not present', () => { - const content = `%macro somemacro();\n%put &sysmacroname;\n%mend;` - const expectedContent = `%macro somemacro();\n%put &sysmacroname;\n%mend somemacro;\n` + const content = ` %macro somemacro;\n %put &sysmacroname;\n %mend;` + const expectedContent = ` %macro somemacro;\n %put &sysmacroname;\n %mend somemacro;` + + const formattedContent = hasMacroNameInMend.fix!(content, new LintConfig()) + + expect(formattedContent).toEqual(expectedContent) + }) + + it('should add macro name to the mend statement if not present ( with multiple macros )', () => { + const content = ` + %macro somemacro; + %put &sysmacroname; + %mend somemacro; + + %macro somemacro2; + %put &sysmacroname2; + %mend;` + const expectedContent = ` + %macro somemacro; + %put &sysmacroname; + %mend somemacro; + + %macro somemacro2; + %put &sysmacroname2; + %mend somemacro2;` const formattedContent = hasMacroNameInMend.fix!(content, new LintConfig()) From 59f7e71919cd5f77f9634df7cb9a783a3aab8a45 Mon Sep 17 00:00:00 2001 From: Saad Jutt Date: Wed, 21 Apr 2021 03:31:14 +0500 Subject: [PATCH 06/12] tests(hasMacroNameInMend): Added more --- src/rules/file/hasMacroNameInMend.spec.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/rules/file/hasMacroNameInMend.spec.ts b/src/rules/file/hasMacroNameInMend.spec.ts index 3dabc2a..3bbb499 100644 --- a/src/rules/file/hasMacroNameInMend.spec.ts +++ b/src/rules/file/hasMacroNameInMend.spec.ts @@ -348,6 +348,15 @@ describe('hasMacroNameInMend', () => { expect(formattedContent).toEqual(expectedContent) }) + it('should add macro name to the mend statement if not present ( code in single line )', () => { + const content = `%macro somemacro; %put &sysmacroname; %mend;` + const expectedContent = `%macro somemacro; %put &sysmacroname; %mend somemacro;` + + const formattedContent = hasMacroNameInMend.fix!(content, new LintConfig()) + + expect(formattedContent).toEqual(expectedContent) + }) + it('should add macro name to the mend statement if not present ( with multiple macros )', () => { const content = ` %macro somemacro; From db2dbb1c69ce7d830108e4866c999054db26dd3f Mon Sep 17 00:00:00 2001 From: Saad Jutt Date: Wed, 21 Apr 2021 16:25:36 +0500 Subject: [PATCH 07/12] feat(format): rules for hasMacroNameInMend --- src/rules/file/hasMacroNameInMend.spec.ts | 77 +++++++++++++++++++-- src/rules/file/hasMacroNameInMend.ts | 82 +++++++++++++++-------- src/utils/parseMacros.ts | 11 ++- 3 files changed, 137 insertions(+), 33 deletions(-) diff --git a/src/rules/file/hasMacroNameInMend.spec.ts b/src/rules/file/hasMacroNameInMend.spec.ts index 3bbb499..9c3d46e 100644 --- a/src/rules/file/hasMacroNameInMend.spec.ts +++ b/src/rules/file/hasMacroNameInMend.spec.ts @@ -339,6 +339,7 @@ describe('hasMacroNameInMend', () => { } ]) }) + it('should add macro name to the mend statement if not present', () => { const content = ` %macro somemacro;\n %put &sysmacroname;\n %mend;` const expectedContent = ` %macro somemacro;\n %put &sysmacroname;\n %mend somemacro;` @@ -349,8 +350,8 @@ describe('hasMacroNameInMend', () => { }) it('should add macro name to the mend statement if not present ( code in single line )', () => { - const content = `%macro somemacro; %put &sysmacroname; %mend;` - const expectedContent = `%macro somemacro; %put &sysmacroname; %mend somemacro;` + const content = `%macro somemacro; %put &sysmacroname; %mend; some code;` + const expectedContent = `%macro somemacro; %put &sysmacroname; %mend somemacro; some code;` const formattedContent = hasMacroNameInMend.fix!(content, new LintConfig()) @@ -380,9 +381,77 @@ describe('hasMacroNameInMend', () => { expect(formattedContent).toEqual(expectedContent) }) + it('should remove redundant %mend statement', () => { + const content = ` + %macro somemacro; + %put &sysmacroname; + %mend somemacro; + %mend something;` + const expectedContent = ` + %macro somemacro; + %put &sysmacroname; + %mend somemacro; + ` + + const formattedContent = hasMacroNameInMend.fix!(content, new LintConfig()) + expect(formattedContent).toEqual(expectedContent) + }) + + it('should remove redundant %mend statement with comments', () => { + const content = ` + %macro somemacro; + %put &sysmacroname; + %mend somemacro; + /* some comment */ + /* some comment */ %mend something; some code; + /* some comment */` + const expectedContent = ` + %macro somemacro; + %put &sysmacroname; + %mend somemacro; + /* some comment */ + /* some comment */ some code; + /* some comment */` + + const formattedContent = hasMacroNameInMend.fix!(content, new LintConfig()) + expect(formattedContent).toEqual(expectedContent) + }) + + it('should correct mismatched macro name', () => { + const content = ` + %macro somemacro; + %put &sysmacroname; + %mend someanothermacro;` + const expectedContent = ` + %macro somemacro; + %put &sysmacroname; + %mend somemacro;` + + const formattedContent = hasMacroNameInMend.fix!(content, new LintConfig()) + expect(formattedContent).toEqual(expectedContent) + }) + + it('should correct mismatched macro name with comments', () => { + const content = ` + %macro somemacro; +/* some comments */ + %put &sysmacroname; +/* some comments */ + %mend someanothermacro ;` + const expectedContent = ` + %macro somemacro; +/* some comments */ + %put &sysmacroname; +/* some comments */ + %mend somemacro ;` + + const formattedContent = hasMacroNameInMend.fix!(content, new LintConfig()) + expect(formattedContent).toEqual(expectedContent) + }) + it('should use the configured line ending while applying the fix', () => { - const content = `%macro somemacro();\r\n%put &sysmacroname;\r\n%mend;` - const expectedContent = `%macro somemacro();\r\n%put &sysmacroname;\r\n%mend somemacro;\r\n` + const content = `%macro somemacro();\r\n%put &sysmacroname;\r\n%mend ;` + const expectedContent = `%macro somemacro();\r\n%put &sysmacroname;\r\n%mend somemacro ;` const formattedContent = hasMacroNameInMend.fix!( content, diff --git a/src/rules/file/hasMacroNameInMend.ts b/src/rules/file/hasMacroNameInMend.ts index 56a4a75..bfb3600 100644 --- a/src/rules/file/hasMacroNameInMend.ts +++ b/src/rules/file/hasMacroNameInMend.ts @@ -18,17 +18,14 @@ const test = (value: string, config?: LintConfig) => { const diagnostics: Diagnostic[] = [] macros.forEach((macro) => { if (macro.startLineNumber === null && macro.endLineNumber !== null) { + const endLine = lines[macro.endLineNumber - 1] diagnostics.push({ message: `%mend statement is redundant`, lineNumber: macro.endLineNumber, - startColumnNumber: getColumnNumber( - lines[macro.endLineNumber - 1], - '%mend' - ), + startColumnNumber: getColumnNumber(endLine, '%mend'), endColumnNumber: - getColumnNumber(lines[macro.endLineNumber - 1], '%mend') + - lines[macro.endLineNumber - 1].trim().length - - 1, + getColumnNumber(endLine, '%mend') + + macro.terminationTrimmedStatement.length, severity: Severity.Warning }) } else if (macro.endLineNumber === null && macro.startLineNumber !== null) { @@ -40,35 +37,29 @@ const test = (value: string, config?: LintConfig) => { severity: Severity.Warning }) } else if (macro.mismatchedMendMacroName) { + const endLine = lines[(macro.endLineNumber as number) - 1] diagnostics.push({ message: `%mend statement has mismatched macro name, it should be '${ macro!.name }'`, lineNumber: macro.endLineNumber as number, startColumnNumber: getColumnNumber( - lines[(macro.endLineNumber as number) - 1], + endLine, macro.mismatchedMendMacroName ), endColumnNumber: - getColumnNumber( - lines[(macro.endLineNumber as number) - 1], - macro.mismatchedMendMacroName - ) + + getColumnNumber(endLine, macro.mismatchedMendMacroName) + macro.mismatchedMendMacroName.length - 1, severity: Severity.Warning }) } else if (!macro.hasMacroNameInMend) { + const endLine = lines[(macro.endLineNumber as number) - 1] diagnostics.push({ message: `%mend statement is missing macro name - ${macro.name}`, lineNumber: macro.endLineNumber as number, - startColumnNumber: getColumnNumber( - lines[(macro.endLineNumber as number) - 1], - '%mend' - ), - endColumnNumber: - getColumnNumber(lines[(macro.endLineNumber as number) - 1], '%mend') + - 6, + startColumnNumber: getColumnNumber(endLine, '%mend'), + endColumnNumber: getColumnNumber(endLine, '%mend') + 6, severity: Severity.Warning }) } @@ -79,16 +70,53 @@ const test = (value: string, config?: LintConfig) => { const fix = (value: string, config?: LintConfig): string => { const lineEnding = config?.lineEndings === LineEndings.CRLF ? '\r\n' : '\n' - let formattedText = value + const lines: string[] = value ? value.split(lineEnding) : [] const macros = parseMacros(value, config) - macros - .filter((macro) => !macro.hasMacroNameInMend) - .forEach((macro) => { - formattedText = formattedText.replace( - macro.termination, - `%mend ${macro.name};${lineEnding}` + + macros.forEach((macro) => { + if (macro.startLineNumber === null && macro.endLineNumber !== null) { + // %mend statement is redundant + const endLine = lines[macro.endLineNumber - 1] + const startColumnNumber = getColumnNumber(endLine, '%mend') + const endColumnNumber = + getColumnNumber(endLine, '%mend') + + macro.terminationTrimmedStatement.length + + 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) { + // missing %mend statement + } else if (macro.mismatchedMendMacroName) { + // mismatched macro name + const endLine = lines[(macro.endLineNumber as number) - 1] + const startColumnNumber = getColumnNumber( + endLine, + macro.mismatchedMendMacroName ) - }) + const endColumnNumber = + getColumnNumber(endLine, macro.mismatchedMendMacroName) + + macro.mismatchedMendMacroName.length - + 1 + + const beforeMacroName = endLine.slice(0, startColumnNumber - 1) + const afterMacroName = endLine.slice(endColumnNumber) + + lines[(macro.endLineNumber as number) - 1] = + beforeMacroName + macro.name + afterMacroName + } else if (!macro.hasMacroNameInMend) { + // %mend statement is missing macro name + const endLine = lines[(macro.endLineNumber as number) - 1] + const startColumnNumber = getColumnNumber(endLine, '%mend') + const endColumnNumber = getColumnNumber(endLine, '%mend') + 4 + + const beforeStatement = endLine.slice(0, startColumnNumber - 1) + const afterStatement = endLine.slice(endColumnNumber) + lines[(macro.endLineNumber as number) - 1] = + beforeStatement + `%mend ${macro.name}` + afterStatement + } + }) + const formattedText = lines.join(lineEnding) return formattedText } diff --git a/src/utils/parseMacros.ts b/src/utils/parseMacros.ts index 65ecd24..8716b86 100644 --- a/src/utils/parseMacros.ts +++ b/src/utils/parseMacros.ts @@ -8,6 +8,8 @@ interface Macro { endLineNumber: number | null declaration: string termination: string + declarationTrimmedStatement: string + terminationTrimmedStatement: string parentMacro: string hasMacroNameInMend: boolean hasParentheses: boolean @@ -53,7 +55,9 @@ export const parseMacros = (text: string, config?: LintConfig): Macro[] => { hasMacroNameInMend: false, mismatchedMendMacroName: '', declaration: line, - termination: '' + termination: '', + declarationTrimmedStatement: trimmedStatement, + terminationTrimmedStatement: '' }) } else if (trimmedStatement.startsWith('%mend')) { if (macroStack.length) { @@ -66,6 +70,7 @@ export const parseMacros = (text: string, config?: LintConfig): Macro[] => { ? '' : mendMacroName macro.termination = line + macro.terminationTrimmedStatement = trimmedStatement macros.push(macro) } else { macros.push({ @@ -77,7 +82,9 @@ export const parseMacros = (text: string, config?: LintConfig): Macro[] => { hasMacroNameInMend: false, mismatchedMendMacroName: '', declaration: '', - termination: line + termination: line, + declarationTrimmedStatement: '', + terminationTrimmedStatement: trimmedStatement }) } } From cd90b0850aec25c294bc70c4467545cbfee5c5c9 Mon Sep 17 00:00:00 2001 From: Saad Jutt Date: Wed, 21 Apr 2021 16:44:20 +0500 Subject: [PATCH 08/12] fix(hasMacroParentheses): added additional test also --- src/rules/file/hasMacroParentheses.spec.ts | 17 ++++++++++++++++- src/rules/file/hasMacroParentheses.ts | 4 +++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/rules/file/hasMacroParentheses.spec.ts b/src/rules/file/hasMacroParentheses.spec.ts index f59b6ce..cc2d706 100644 --- a/src/rules/file/hasMacroParentheses.spec.ts +++ b/src/rules/file/hasMacroParentheses.spec.ts @@ -44,6 +44,21 @@ describe('hasMacroParentheses', () => { ]) }) + it('should return an array with a single diagnostic when macro defined without name ( single line code )', () => { + const content = ` + %macro (); %put &sysmacroname; %mend;` + + expect(hasMacroParentheses.test(content)).toEqual([ + { + message: 'Macro definition missing name', + lineNumber: 2, + startColumnNumber: 3, + endColumnNumber: 12, + severity: Severity.Warning + } + ]) + }) + it('should return an array with a single diagnostic when macro defined without name and parentheses', () => { const content = ` %macro ; @@ -55,7 +70,7 @@ describe('hasMacroParentheses', () => { message: 'Macro definition missing name', lineNumber: 2, startColumnNumber: 3, - endColumnNumber: 10, + endColumnNumber: 9, severity: Severity.Warning } ]) diff --git a/src/rules/file/hasMacroParentheses.ts b/src/rules/file/hasMacroParentheses.ts index f2eef10..a998a46 100644 --- a/src/rules/file/hasMacroParentheses.ts +++ b/src/rules/file/hasMacroParentheses.ts @@ -18,7 +18,9 @@ const test = (value: string, config?: LintConfig) => { message: 'Macro definition missing name', lineNumber: macro.startLineNumber!, startColumnNumber: getColumnNumber(macro.declaration, '%macro'), - endColumnNumber: macro.declaration.length, + endColumnNumber: + getColumnNumber(macro.declaration, '%macro') + + macro.declarationTrimmedStatement.length, severity: Severity.Warning }) } else if (!macro.declaration.includes('(')) { From 060b838f218226e857eb298730dcc1e08c99d85b Mon Sep 17 00:00:00 2001 From: Saad Jutt Date: Wed, 21 Apr 2021 16:51:13 +0500 Subject: [PATCH 09/12] test(*): removed extra lineEndings --- src/format/formatFile.spec.ts | 4 ++-- src/format/formatFolder.spec.ts | 4 ++-- src/format/formatProject.spec.ts | 2 +- src/format/formatText.spec.ts | 4 ++-- src/utils/parseMacros.spec.ts | 10 ++++++++++ 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/format/formatFile.spec.ts b/src/format/formatFile.spec.ts index d4ba1b5..4581596 100644 --- a/src/format/formatFile.spec.ts +++ b/src/format/formatFile.spec.ts @@ -6,7 +6,7 @@ import { LintConfig } from '../types' describe('formatFile', () => { it('should fix linting issues in a given file', async () => { const content = `%macro somemacro(); \n%put 'hello';\n%mend;` - const expectedContent = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;\n` + const expectedContent = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;` await createFile(path.join(__dirname, 'format-file-test.sas'), content) await formatFile(path.join(__dirname, 'format-file-test.sas')) @@ -19,7 +19,7 @@ describe('formatFile', () => { it('should use the provided config if available', async () => { const content = `%macro somemacro(); \n%put 'hello';\n%mend;` - const expectedContent = `/**\r\n @file\r\n @brief \r\n

SAS Macros

\r\n**/\r\n%macro somemacro();\r\n%put 'hello';\r\n%mend somemacro;\r\n` + const expectedContent = `/**\r\n @file\r\n @brief \r\n

SAS Macros

\r\n**/\r\n%macro somemacro();\r\n%put 'hello';\r\n%mend somemacro;` await createFile(path.join(__dirname, 'format-file-config.sas'), content) await formatFile( diff --git a/src/format/formatFolder.spec.ts b/src/format/formatFolder.spec.ts index 5bda372..389813b 100644 --- a/src/format/formatFolder.spec.ts +++ b/src/format/formatFolder.spec.ts @@ -10,7 +10,7 @@ import { describe('formatFolder', () => { it('should fix linting issues in a given folder', async () => { const content = `%macro somemacro(); \n%put 'hello';\n%mend;` - const expectedContent = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;\n` + const expectedContent = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;` await createFolder(path.join(__dirname, 'format-folder-test')) await createFile( path.join(__dirname, 'format-folder-test', 'format-folder-test.sas'), @@ -29,7 +29,7 @@ describe('formatFolder', () => { it('should fix linting issues in subfolders of a given folder', async () => { const content = `%macro somemacro(); \n%put 'hello';\n%mend;` - const expectedContent = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;\n` + const expectedContent = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;` await createFolder(path.join(__dirname, 'format-folder-test')) await createFolder(path.join(__dirname, 'subfolder')) await createFile( diff --git a/src/format/formatProject.spec.ts b/src/format/formatProject.spec.ts index dc275eb..7f2cfa6 100644 --- a/src/format/formatProject.spec.ts +++ b/src/format/formatProject.spec.ts @@ -13,7 +13,7 @@ jest.mock('../utils/getProjectRoot') describe('formatProject', () => { it('should format files in the current project', async () => { const content = `%macro somemacro(); \n%put 'hello';\n%mend;` - const expectedContent = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;\n` + const expectedContent = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;` await createFolder(path.join(__dirname, 'format-project-test')) await createFile( path.join(__dirname, 'format-project-test', 'format-project-test.sas'), diff --git a/src/format/formatText.spec.ts b/src/format/formatText.spec.ts index 03b097b..35f2a53 100644 --- a/src/format/formatText.spec.ts +++ b/src/format/formatText.spec.ts @@ -20,7 +20,7 @@ describe('formatText', () => { @brief

SAS Macros

**/\n%macro test - %put 'hello';\n%mend test;\n` + %put 'hello';\n%mend test;` const output = await formatText(text) @@ -40,7 +40,7 @@ describe('formatText', () => { ) 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;\r\n` + 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/utils/parseMacros.spec.ts b/src/utils/parseMacros.spec.ts index 1367523..5aaece8 100644 --- a/src/utils/parseMacros.spec.ts +++ b/src/utils/parseMacros.spec.ts @@ -14,6 +14,8 @@ describe('parseMacros', () => { name: 'test', declaration: '%macro test;', termination: '%mend', + declarationTrimmedStatement: '%macro test', + terminationTrimmedStatement: '%mend', startLineNumber: 1, endLineNumber: 3, parentMacro: '', @@ -38,6 +40,8 @@ describe('parseMacros', () => { name: 'foo', declaration: '%macro foo;', termination: '%mend;', + declarationTrimmedStatement: '%macro foo', + terminationTrimmedStatement: '%mend', startLineNumber: 1, endLineNumber: 3, parentMacro: '', @@ -49,6 +53,8 @@ describe('parseMacros', () => { name: 'bar', declaration: '%macro bar();', termination: '%mend bar;', + declarationTrimmedStatement: '%macro bar()', + terminationTrimmedStatement: '%mend bar', startLineNumber: 4, endLineNumber: 6, parentMacro: '', @@ -73,6 +79,8 @@ describe('parseMacros', () => { name: 'test', declaration: '%macro test()', termination: '%mend test', + declarationTrimmedStatement: '%macro test()', + terminationTrimmedStatement: '%mend test', startLineNumber: 1, endLineNumber: 6, parentMacro: '', @@ -84,6 +92,8 @@ describe('parseMacros', () => { name: 'test2', declaration: ' %macro test2', termination: ' %mend', + declarationTrimmedStatement: '%macro test2', + terminationTrimmedStatement: '%mend', startLineNumber: 3, endLineNumber: 5, parentMacro: 'test', From abc2f75dc0dd2a352cb4e3fe7b02520218ded1d7 Mon Sep 17 00:00:00 2001 From: Krishna Acondy Date: Wed, 21 Apr 2021 15:10:28 +0100 Subject: [PATCH 10/12] chore(*): rename macro properties --- src/rules/file/hasMacroNameInMend.ts | 6 ++--- src/rules/file/hasMacroParentheses.ts | 16 ++++++------- src/utils/parseMacros.spec.ts | 34 +++++++++++++-------------- src/utils/parseMacros.ts | 22 ++++++++--------- 4 files changed, 38 insertions(+), 40 deletions(-) diff --git a/src/rules/file/hasMacroNameInMend.ts b/src/rules/file/hasMacroNameInMend.ts index bfb3600..cab7ac1 100644 --- a/src/rules/file/hasMacroNameInMend.ts +++ b/src/rules/file/hasMacroNameInMend.ts @@ -24,8 +24,7 @@ const test = (value: string, config?: LintConfig) => { lineNumber: macro.endLineNumber, startColumnNumber: getColumnNumber(endLine, '%mend'), endColumnNumber: - getColumnNumber(endLine, '%mend') + - macro.terminationTrimmedStatement.length, + getColumnNumber(endLine, '%mend') + macro.termination.length, severity: Severity.Warning }) } else if (macro.endLineNumber === null && macro.startLineNumber !== null) { @@ -79,8 +78,7 @@ const fix = (value: string, config?: LintConfig): string => { const endLine = lines[macro.endLineNumber - 1] const startColumnNumber = getColumnNumber(endLine, '%mend') const endColumnNumber = - getColumnNumber(endLine, '%mend') + - macro.terminationTrimmedStatement.length + getColumnNumber(endLine, '%mend') + macro.termination.length const beforeStatement = endLine.slice(0, startColumnNumber - 1) const afterStatement = endLine.slice(endColumnNumber) diff --git a/src/rules/file/hasMacroParentheses.ts b/src/rules/file/hasMacroParentheses.ts index a998a46..7975cba 100644 --- a/src/rules/file/hasMacroParentheses.ts +++ b/src/rules/file/hasMacroParentheses.ts @@ -17,19 +17,19 @@ const test = (value: string, config?: LintConfig) => { diagnostics.push({ message: 'Macro definition missing name', lineNumber: macro.startLineNumber!, - startColumnNumber: getColumnNumber(macro.declaration, '%macro'), + startColumnNumber: getColumnNumber(macro.declarationLine, '%macro'), endColumnNumber: - getColumnNumber(macro.declaration, '%macro') + - macro.declarationTrimmedStatement.length, + getColumnNumber(macro.declarationLine, '%macro') + + macro.declaration.length, severity: Severity.Warning }) - } else if (!macro.declaration.includes('(')) { + } else if (!macro.declarationLine.includes('(')) { diagnostics.push({ message, lineNumber: macro.startLineNumber!, - startColumnNumber: getColumnNumber(macro.declaration, macro.name), + startColumnNumber: getColumnNumber(macro.declarationLine, macro.name), endColumnNumber: - getColumnNumber(macro.declaration, macro.name) + + getColumnNumber(macro.declarationLine, macro.name) + macro.name.length - 1, severity: Severity.Warning @@ -38,9 +38,9 @@ const test = (value: string, config?: LintConfig) => { diagnostics.push({ message: 'Macro definition contains space(s)', lineNumber: macro.startLineNumber!, - startColumnNumber: getColumnNumber(macro.declaration, macro.name), + startColumnNumber: getColumnNumber(macro.declarationLine, macro.name), endColumnNumber: - getColumnNumber(macro.declaration, macro.name) + + getColumnNumber(macro.declarationLine, macro.name) + macro.name.length - 1 + `()`.length, diff --git a/src/utils/parseMacros.spec.ts b/src/utils/parseMacros.spec.ts index 5aaece8..8a90525 100644 --- a/src/utils/parseMacros.spec.ts +++ b/src/utils/parseMacros.spec.ts @@ -12,10 +12,10 @@ describe('parseMacros', () => { expect(macros.length).toEqual(1) expect(macros).toContainEqual({ name: 'test', - declaration: '%macro test;', + declarationLine: '%macro test;', + terminationLine: '%mend', + declaration: '%macro test', termination: '%mend', - declarationTrimmedStatement: '%macro test', - terminationTrimmedStatement: '%mend', startLineNumber: 1, endLineNumber: 3, parentMacro: '', @@ -38,10 +38,10 @@ describe('parseMacros', () => { expect(macros.length).toEqual(2) expect(macros).toContainEqual({ name: 'foo', - declaration: '%macro foo;', - termination: '%mend;', - declarationTrimmedStatement: '%macro foo', - terminationTrimmedStatement: '%mend', + declarationLine: '%macro foo;', + terminationLine: '%mend;', + declaration: '%macro foo', + termination: '%mend', startLineNumber: 1, endLineNumber: 3, parentMacro: '', @@ -51,10 +51,10 @@ describe('parseMacros', () => { }) expect(macros).toContainEqual({ name: 'bar', - declaration: '%macro bar();', - termination: '%mend bar;', - declarationTrimmedStatement: '%macro bar()', - terminationTrimmedStatement: '%mend bar', + declarationLine: '%macro bar();', + terminationLine: '%mend bar;', + declaration: '%macro bar()', + termination: '%mend bar', startLineNumber: 4, endLineNumber: 6, parentMacro: '', @@ -77,10 +77,10 @@ describe('parseMacros', () => { expect(macros.length).toEqual(2) expect(macros).toContainEqual({ name: 'test', + declarationLine: '%macro test()', + terminationLine: '%mend test', declaration: '%macro test()', termination: '%mend test', - declarationTrimmedStatement: '%macro test()', - terminationTrimmedStatement: '%mend test', startLineNumber: 1, endLineNumber: 6, parentMacro: '', @@ -90,10 +90,10 @@ describe('parseMacros', () => { }) expect(macros).toContainEqual({ name: 'test2', - declaration: ' %macro test2', - termination: ' %mend', - declarationTrimmedStatement: '%macro test2', - terminationTrimmedStatement: '%mend', + declarationLine: ' %macro test2', + terminationLine: ' %mend', + declaration: '%macro test2', + termination: '%mend', startLineNumber: 3, endLineNumber: 5, parentMacro: 'test', diff --git a/src/utils/parseMacros.ts b/src/utils/parseMacros.ts index 8716b86..77a6515 100644 --- a/src/utils/parseMacros.ts +++ b/src/utils/parseMacros.ts @@ -6,10 +6,10 @@ interface Macro { name: string startLineNumber: number | null endLineNumber: number | null + declarationLine: string + terminationLine: string declaration: string termination: string - declarationTrimmedStatement: string - terminationTrimmedStatement: string parentMacro: string hasMacroNameInMend: boolean hasParentheses: boolean @@ -54,10 +54,10 @@ export const parseMacros = (text: string, config?: LintConfig): Macro[] => { hasParentheses: trimmedStatement.endsWith('()'), hasMacroNameInMend: false, mismatchedMendMacroName: '', - declaration: line, - termination: '', - declarationTrimmedStatement: trimmedStatement, - terminationTrimmedStatement: '' + declarationLine: line, + terminationLine: '', + declaration: trimmedStatement, + termination: '' }) } else if (trimmedStatement.startsWith('%mend')) { if (macroStack.length) { @@ -69,8 +69,8 @@ export const parseMacros = (text: string, config?: LintConfig): Macro[] => { macro.mismatchedMendMacroName = macro.hasMacroNameInMend ? '' : mendMacroName - macro.termination = line - macro.terminationTrimmedStatement = trimmedStatement + macro.terminationLine = line + macro.termination = trimmedStatement macros.push(macro) } else { macros.push({ @@ -81,10 +81,10 @@ export const parseMacros = (text: string, config?: LintConfig): Macro[] => { hasParentheses: false, hasMacroNameInMend: false, mismatchedMendMacroName: '', + declarationLine: '', + terminationLine: line, declaration: '', - termination: line, - declarationTrimmedStatement: '', - terminationTrimmedStatement: trimmedStatement + termination: trimmedStatement }) } } From 3da3e1e134ce2cdd9b7d26b04af9233f1f72ab8e Mon Sep 17 00:00:00 2001 From: Krishna Acondy Date: Wed, 21 Apr 2021 15:17:16 +0100 Subject: [PATCH 11/12] fix(macros): check for exact match with macro name --- src/utils/parseMacros.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/parseMacros.ts b/src/utils/parseMacros.ts index 77a6515..6ee838a 100644 --- a/src/utils/parseMacros.ts +++ b/src/utils/parseMacros.ts @@ -65,7 +65,7 @@ export const parseMacros = (text: string, config?: LintConfig): Macro[] => { const mendMacroName = trimmedStatement.split(' ').filter((s: string) => !!s)[1] || '' macro.endLineNumber = index + 1 - macro.hasMacroNameInMend = trimmedStatement.includes(macro.name) + macro.hasMacroNameInMend = mendMacroName === macro.name macro.mismatchedMendMacroName = macro.hasMacroNameInMend ? '' : mendMacroName From 2687a8fa462affb26e4410c223a6ead5faa7467b Mon Sep 17 00:00:00 2001 From: Krishna Acondy Date: Wed, 21 Apr 2021 15:22:02 +0100 Subject: [PATCH 12/12] chore(*): separate tests for test and fix functions --- src/rules/file/hasDoxygenHeader.spec.ts | 4 +++- src/rules/file/hasMacroNameInMend.spec.ts | 4 +++- src/rules/file/lineEndings.spec.ts | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/rules/file/hasDoxygenHeader.spec.ts b/src/rules/file/hasDoxygenHeader.spec.ts index 5cd2e2d..02d0ca4 100644 --- a/src/rules/file/hasDoxygenHeader.spec.ts +++ b/src/rules/file/hasDoxygenHeader.spec.ts @@ -2,7 +2,7 @@ import { LintConfig } from '../../types' import { Severity } from '../../types/Severity' import { hasDoxygenHeader } from './hasDoxygenHeader' -describe('hasDoxygenHeader', () => { +describe('hasDoxygenHeader - test', () => { it('should return an empty array when the file starts with a doxygen header', () => { const content = `/** @file @@ -69,7 +69,9 @@ describe('hasDoxygenHeader', () => { } ]) }) +}) +describe('hasDoxygenHeader - fix', () => { it('should not alter the text if a doxygen header is already present', () => { const content = `/** @file diff --git a/src/rules/file/hasMacroNameInMend.spec.ts b/src/rules/file/hasMacroNameInMend.spec.ts index 9c3d46e..6daf1bf 100644 --- a/src/rules/file/hasMacroNameInMend.spec.ts +++ b/src/rules/file/hasMacroNameInMend.spec.ts @@ -2,7 +2,7 @@ import { LintConfig } from '../../types' import { Severity } from '../../types/Severity' import { hasMacroNameInMend } from './hasMacroNameInMend' -describe('hasMacroNameInMend', () => { +describe('hasMacroNameInMend - test', () => { it('should return an empty array when %mend has correct macro name', () => { const content = ` %macro somemacro(); @@ -339,7 +339,9 @@ describe('hasMacroNameInMend', () => { } ]) }) +}) +describe('hasMacroNameInMend - fix', () => { it('should add macro name to the mend statement if not present', () => { const content = ` %macro somemacro;\n %put &sysmacroname;\n %mend;` const expectedContent = ` %macro somemacro;\n %put &sysmacroname;\n %mend somemacro;` diff --git a/src/rules/file/lineEndings.spec.ts b/src/rules/file/lineEndings.spec.ts index d50bf24..06ff58b 100644 --- a/src/rules/file/lineEndings.spec.ts +++ b/src/rules/file/lineEndings.spec.ts @@ -2,7 +2,7 @@ import { LintConfig, Severity } from '../../types' import { LineEndings } from '../../types/LineEndings' import { lineEndings } from './lineEndings' -describe('lineEndings', () => { +describe('lineEndings - test', () => { it('should return an empty array when the text contains the configured line endings', () => { const text = "%put 'hello';\n%put 'world';\n" const config = new LintConfig({ lineEndings: LineEndings.LF }) @@ -101,7 +101,9 @@ describe('lineEndings', () => { severity: Severity.Warning }) }) +}) +describe('lineEndings - fix', () => { it('should transform line endings to LF', () => { const text = "%put 'hello';\r\n%put 'test';\r\n%put 'world';\n%put 'test2';\n%put 'world2';\r\n"