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,99 +2,97 @@ 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) { + 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 + }) + } }) return diagnostics } +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}` + ) + }) + + return formattedText +} + /** * Lint rule that checks for the presence of macro name in %mend statement. */ @@ -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) +}