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/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/formatFile.spec.ts b/src/format/formatFile.spec.ts new file mode 100644 index 0000000..4581596 --- /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;` + 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;` + 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..389813b --- /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;` + 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;` + 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..7f2cfa6 --- /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;` + 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/formatText.spec.ts b/src/format/formatText.spec.ts new file mode 100644 index 0000000..35f2a53 --- /dev/null +++ b/src/format/formatText.spec.ts @@ -0,0 +1,49 @@ +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 +

SAS Macros

+**/\n%macro test + %put 'hello';\n%mend test;` + + 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

SAS Macros

\r\n**/\r\n%macro test\r\n %put 'hello';\r\n%mend test;` + + 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/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/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/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,18 @@ 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, '..', 'Example File.sas') + path.join(__dirname, 'lint-folder-test', 'Example File.sas') )! expect(diagnostics.length).toEqual(expectedDiagnostics.length) expect(diagnostics).toContainEqual(expectedDiagnostics[0]) @@ -87,5 +100,36 @@ 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')) + }) + + 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]) + 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')) }) }) diff --git a/src/lint/lintProject.spec.ts b/src/lint/lintProject.spec.ts index 47e09d9..31a3151 100644 --- a/src/lint/lintProject.spec.ts +++ b/src/lint/lintProject.spec.ts @@ -1,8 +1,10 @@ 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') +import { createFolder, createFile, readFile, deleteFolder } from '@sasjs/utils' +import { DefaultLintConfiguration } from '../utils' +jest.mock('../utils/getProjectRoot') const expectedFilesCount = 1 const expectedDiagnostics = [ @@ -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(utils, 'getProjectRoot') - .mockImplementationOnce(() => Promise.resolve(path.join(__dirname, '..'))) + .spyOn(getProjectRootModule, 'getProjectRoot') + .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,11 +109,13 @@ 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 () => { 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..02d0ca4 100644 --- a/src/rules/file/hasDoxygenHeader.spec.ts +++ b/src/rules/file/hasDoxygenHeader.spec.ts @@ -1,7 +1,8 @@ +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,3 +70,47 @@ describe('hasDoxygenHeader', () => { ]) }) }) + +describe('hasDoxygenHeader - fix', () => { + 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 +

SAS Macros

+**/` + + '\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

SAS Macros

\r\n**/` + + '\r\n' + + content + ) + }) +}) diff --git a/src/rules/file/hasDoxygenHeader.ts b/src/rules/file/hasDoxygenHeader.ts index aa7c0bc..2a5c75b 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}

SAS Macros

{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..6daf1bf 100644 --- a/src/rules/file/hasMacroNameInMend.spec.ts +++ b/src/rules/file/hasMacroNameInMend.spec.ts @@ -1,7 +1,8 @@ +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(); @@ -319,4 +320,146 @@ 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 + } + ]) + }) +}) + +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;` + + const formattedContent = hasMacroNameInMend.fix!(content, new LintConfig()) + + 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; some code;` + const expectedContent = `%macro somemacro; %put &sysmacroname; %mend somemacro; some code;` + + 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()) + + 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 ;` + + 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..cab7ac1 100644 --- a/src/rules/file/hasMacroNameInMend.ts +++ b/src/rules/file/hasMacroNameInMend.ts @@ -2,99 +2,123 @@ 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[] = [] - - 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(';') : [] - - 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 (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 - }) + 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(endLine, '%mend'), + endColumnNumber: + getColumnNumber(endLine, '%mend') + macro.termination.length, + 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) { + 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( + endLine, + macro.mismatchedMendMacroName + ), + endColumnNumber: + 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(endLine, '%mend'), + endColumnNumber: getColumnNumber(endLine, '%mend') + 6, + severity: Severity.Warning + }) + } }) return diagnostics } +const fix = (value: string, config?: LintConfig): string => { + const lineEnding = config?.lineEndings === LineEndings.CRLF ? '\r\n' : '\n' + const lines: string[] = value ? value.split(lineEnding) : [] + const macros = parseMacros(value, config) + + 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.termination.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 +} + /** * Lint rule that checks for the presence of macro name in %mend statement. */ @@ -103,5 +127,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..cc2d706 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,22 @@ 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 ( 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 ; %put &sysmacroname; diff --git a/src/rules/file/hasMacroParentheses.ts b/src/rules/file/hasMacroParentheses.ts index 9597674..7975cba 100644 --- a/src/rules/file/hasMacroParentheses.ts +++ b/src/rules/file/hasMacroParentheses.ts @@ -2,74 +2,53 @@ 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.declarationLine, '%macro'), + endColumnNumber: + getColumnNumber(macro.declarationLine, '%macro') + + macro.declaration.length, + severity: Severity.Warning + }) + } else if (!macro.declarationLine.includes('(')) { + diagnostics.push({ + message, + lineNumber: macro.startLineNumber!, + startColumnNumber: getColumnNumber(macro.declarationLine, macro.name), + endColumnNumber: + getColumnNumber(macro.declarationLine, macro.name) + + macro.name.length - + 1, + severity: Severity.Warning + }) + } else if (macro.name !== macro.name.trim()) { + diagnostics.push({ + message: 'Macro definition contains space(s)', + lineNumber: macro.startLineNumber!, + startColumnNumber: getColumnNumber(macro.declarationLine, macro.name), + endColumnNumber: + getColumnNumber(macro.declarationLine, macro.name) + + macro.name.length - + 1 + + `()`.length, + severity: Severity.Warning + }) + } }) + 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..06ff58b --- /dev/null +++ b/src/rules/file/lineEndings.spec.ts @@ -0,0 +1,141 @@ +import { LintConfig, Severity } from '../../types' +import { LineEndings } from '../../types/LineEndings' +import { lineEndings } from './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 }) + 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 + }) + }) +}) + +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" + 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..8a90525 --- /dev/null +++ b/src/utils/parseMacros.spec.ts @@ -0,0 +1,105 @@ +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', + declarationLine: '%macro test;', + terminationLine: '%mend', + 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', + declarationLine: '%macro foo;', + terminationLine: '%mend;', + declaration: '%macro foo', + termination: '%mend', + startLineNumber: 1, + endLineNumber: 3, + parentMacro: '', + hasMacroNameInMend: false, + hasParentheses: false, + mismatchedMendMacroName: '' + }) + expect(macros).toContainEqual({ + name: 'bar', + declarationLine: '%macro bar();', + terminationLine: '%mend 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', + declarationLine: '%macro test()', + terminationLine: '%mend test', + declaration: '%macro test()', + termination: '%mend test', + startLineNumber: 1, + endLineNumber: 6, + parentMacro: '', + hasMacroNameInMend: true, + hasParentheses: true, + mismatchedMendMacroName: '' + }) + expect(macros).toContainEqual({ + name: 'test2', + declarationLine: ' %macro test2', + terminationLine: ' %mend', + 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..6ee838a --- /dev/null +++ b/src/utils/parseMacros.ts @@ -0,0 +1,97 @@ +import { LintConfig } from '../types/LintConfig' +import { LineEndings } from '../types/LineEndings' +import { trimComments } from './trimComments' + +interface Macro { + name: string + startLineNumber: number | null + endLineNumber: number | null + declarationLine: string + terminationLine: string + declaration: string + termination: string + parentMacro: string + hasMacroNameInMend: boolean + hasParentheses: boolean + mismatchedMendMacroName: string +} + +export const parseMacros = (text: string, config?: LintConfig): Macro[] => { + const lineEnding = config?.lineEndings === LineEndings.CRLF ? '\r\n' : '\n' + const lines: string[] = text ? text.split(lineEnding) : [] + 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: '', + declarationLine: line, + terminationLine: '', + declaration: trimmedStatement, + 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 = mendMacroName === macro.name + macro.mismatchedMendMacroName = macro.hasMacroNameInMend + ? '' + : mendMacroName + macro.terminationLine = line + macro.termination = trimmedStatement + macros.push(macro) + } else { + macros.push({ + name: '', + startLineNumber: null, + endLineNumber: index + 1, + parentMacro: '', + hasParentheses: false, + hasMacroNameInMend: false, + mismatchedMendMacroName: '', + declarationLine: '', + terminationLine: line, + declaration: '', + termination: trimmedStatement + }) + } + } + }) + }) + + 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) +}