├── src ├── types │ ├── LineEndings.ts │ ├── Process.d.ts │ ├── Severity.ts │ ├── index.ts │ ├── LintRuleType.ts │ ├── FormatResult.ts │ ├── Macro.ts │ ├── Diagnostic.ts │ ├── LintRule.ts │ ├── LintConfig.ts │ └── LintConfig.spec.ts ├── index.ts ├── lint │ ├── index.ts │ ├── lintText.ts │ ├── lintProject.ts │ ├── lintFile.ts │ ├── shared.ts │ ├── lintFolder.ts │ ├── lintText.spec.ts │ ├── lintFile.spec.ts │ ├── lintProject.spec.ts │ └── lintFolder.spec.ts ├── rules │ ├── path │ │ ├── index.ts │ │ ├── noSpacesInFileNames.spec.ts │ │ ├── noSpacesInFileNames.ts │ │ ├── lowerCaseFileNames.ts │ │ └── lowerCaseFileNames.spec.ts │ ├── line │ │ ├── index.ts │ │ ├── noTabs.spec.ts │ │ ├── noTrailingSpaces.spec.ts │ │ ├── noGremlins.spec.ts │ │ ├── noTabs.ts │ │ ├── noTrailingSpaces.ts │ │ ├── noEncodedPasswords.ts │ │ ├── indentationMultiple.ts │ │ ├── maxLineLength.ts │ │ ├── noEncodedPasswords.spec.ts │ │ ├── noGremlins.ts │ │ ├── indentationMultiple.spec.ts │ │ └── maxLineLength.spec.ts │ └── file │ │ ├── index.ts │ │ ├── hasRequiredMacroOptions.ts │ │ ├── noNestedMacros.ts │ │ ├── hasMacroParentheses.ts │ │ ├── hasDoxygenHeader.ts │ │ ├── noNestedMacros.spec.ts │ │ ├── lineEndings.ts │ │ ├── hasMacroParentheses.spec.ts │ │ ├── hasRequiredMacroOptions.spec.ts │ │ ├── hasDoxygenHeader.spec.ts │ │ ├── lineEndings.spec.ts │ │ ├── hasMacroNameInMend.ts │ │ ├── strictMacroDefinition.ts │ │ └── strictMacroDefinition.spec.ts ├── format │ ├── index.ts │ ├── formatText.ts │ ├── formatProject.ts │ ├── shared.ts │ ├── formatText.spec.ts │ ├── formatFile.ts │ ├── formatProject.spec.ts │ ├── formatFolder.ts │ ├── formatFile.spec.ts │ └── formatFolder.spec.ts ├── utils │ ├── getColumnNumber.ts │ ├── asyncForEach.ts │ ├── index.ts │ ├── listSasFiles.ts │ ├── getColumnNumber.spec.ts │ ├── getIndicesOf.ts │ ├── asyncForEach.spec.ts │ ├── getHeaderLinesCount.ts │ ├── getHeaderLinesCount.spec.ts │ ├── getProjectRoot.spec.ts │ ├── splitText.ts │ ├── getProjectRoot.ts │ ├── getLintConfig.spec.ts │ ├── isIgnored.ts │ ├── splitText.spec.ts │ ├── trimComments.ts │ ├── getDataSectionsDetail.ts │ ├── getLintConfig.ts │ ├── trimComments.spec.ts │ ├── isIgnored.spec.ts │ ├── getDataSectionDetail.spec.ts │ ├── parseMacros.ts │ ├── gremlinCharacters.ts │ └── parseMacros.spec.ts ├── Example File.sas ├── formatExample.ts └── lintExample.ts ├── .prettierrc ├── .github ├── dependabot.yml ├── reviewer-lottery.yml ├── workflows │ ├── assign-reviewer.yml │ ├── publish.yml │ └── build.yml ├── PULL_REQUEST_TEMPLATE.md └── CONTRIBUTING.md ├── .gitpod.yml ├── jest.config.js ├── .sasjslint ├── checkNodeVersion.js ├── tsconfig.json ├── .git-hooks └── commit-msg ├── LICENSE ├── package.json ├── .gitignore └── .all-contributorsrc /src/types/LineEndings.ts: -------------------------------------------------------------------------------- 1 | export enum LineEndings { 2 | LF = 'lf', 3 | CRLF = 'crlf', 4 | OFF = 'off' 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './format' 2 | export * from './lint' 3 | export * from './types' 4 | export * from './utils' 5 | -------------------------------------------------------------------------------- /src/lint/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lintText' 2 | export * from './lintFile' 3 | export * from './lintFolder' 4 | export * from './lintProject' 5 | -------------------------------------------------------------------------------- /src/rules/path/index.ts: -------------------------------------------------------------------------------- 1 | export { lowerCaseFileNames } from './lowerCaseFileNames' 2 | export { noSpacesInFileNames } from './noSpacesInFileNames' 3 | -------------------------------------------------------------------------------- /src/format/index.ts: -------------------------------------------------------------------------------- 1 | export * from './formatText' 2 | export * from './formatFile' 3 | export * from './formatFolder' 4 | export * from './formatProject' 5 | -------------------------------------------------------------------------------- /src/types/Process.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | export interface Process { 3 | projectDir: string 4 | currentDir: string 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/types/Severity.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Severity indicates the seriousness of a given violation. 3 | */ 4 | export enum Severity { 5 | Info, 6 | Warning, 7 | Error 8 | } 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 3 8 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Diagnostic' 2 | export * from './FormatResult' 3 | export * from './LintConfig' 4 | export * from './LintRule' 5 | export * from './LintRuleType' 6 | export * from './Severity' 7 | export * from './Macro' 8 | -------------------------------------------------------------------------------- /src/types/LintRuleType.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The types of lint rules available. 3 | * This might be expanded to include lint rules for entire folders and projects. 4 | */ 5 | export enum LintRuleType { 6 | Line, 7 | File, 8 | Path 9 | } 10 | -------------------------------------------------------------------------------- /.github/reviewer-lottery.yml: -------------------------------------------------------------------------------- 1 | groups: 2 | - name: SASjs Devs # name of the group 3 | reviewers: 1 # how many reviewers do you want to assign? 4 | usernames: # github usernames of the reviewers 5 | - YuryShkoda 6 | - medjedovicm 7 | - allanbowe 8 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was automatically generated by Gitpod. 2 | # Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file) 3 | # and commit this file to your remote git repository to share the goodness with others. 4 | 5 | tasks: 6 | - init: npm install && npm run build 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/rules/line/index.ts: -------------------------------------------------------------------------------- 1 | export { noGremlins } from './noGremlins' 2 | export { indentationMultiple } from './indentationMultiple' 3 | export { maxLineLength } from './maxLineLength' 4 | export { noEncodedPasswords } from './noEncodedPasswords' 5 | export { noTabs } from './noTabs' 6 | export { noTrailingSpaces } from './noTrailingSpaces' 7 | -------------------------------------------------------------------------------- /.github/workflows/assign-reviewer.yml: -------------------------------------------------------------------------------- 1 | name: Assign Reviewer 2 | on: 3 | pull_request: 4 | types: [opened] 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: uesteibar/reviewer-lottery@v3 12 | with: 13 | repo-token: ${{ secrets.GH_TOKEN }} -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | coverageThreshold: { 5 | global: { 6 | branches: 80, 7 | functions: 80, 8 | lines: 80, 9 | statements: -10 10 | } 11 | }, 12 | collectCoverageFrom: ['src/**/{!(index|formatExample|lintExample),}.ts'] 13 | } 14 | -------------------------------------------------------------------------------- /src/format/formatText.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig } from '../types' 2 | import { getLintConfig } from '../utils' 3 | import { processText } from './shared' 4 | 5 | export const formatText = async (text: string, configuration?: LintConfig) => { 6 | const config = configuration || (await getLintConfig()) 7 | return processText(text, config) 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/getColumnNumber.ts: -------------------------------------------------------------------------------- 1 | export const getColumnNumber = (line: string, text: string): number => { 2 | const index = (line.split('\n').pop() as string).indexOf(text) 3 | if (index < 0) { 4 | throw new Error(`String '${text}' was not found in line '${line}'`) 5 | } 6 | return (line.split('\n').pop() as string).indexOf(text) + 1 7 | } 8 | -------------------------------------------------------------------------------- /src/types/FormatResult.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic } from './Diagnostic' 2 | 3 | /** 4 | * Represents the result of a format operation on a file, folder or project. 5 | */ 6 | export interface FormatResult { 7 | updatedFilePaths: string[] 8 | fixedDiagnosticsCount: number 9 | unfixedDiagnostics: Map | Diagnostic[] 10 | } 11 | -------------------------------------------------------------------------------- /src/types/Macro.ts: -------------------------------------------------------------------------------- 1 | export interface Macro { 2 | name: string 3 | startLineNumbers: number[] 4 | endLineNumber: number | null 5 | declarationLines: string[] 6 | terminationLine: string 7 | declaration: string 8 | termination: string 9 | parentMacro: string 10 | hasMacroNameInMend: boolean 11 | mismatchedMendMacroName: string 12 | } 13 | -------------------------------------------------------------------------------- /src/types/Diagnostic.ts: -------------------------------------------------------------------------------- 1 | import { Severity } from './Severity' 2 | 3 | /** 4 | * A diagnostic is produced by the execution of a lint rule against a file or line of text. 5 | */ 6 | export interface Diagnostic { 7 | lineNumber: number 8 | startColumnNumber: number 9 | endColumnNumber: number 10 | message: string 11 | severity: Severity 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/asyncForEach.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Executes an async callback for each item in the given array. 3 | */ 4 | export async function asyncForEach( 5 | array: any[], 6 | callback: (item: any, index: number, originalArray: any[]) => any 7 | ) { 8 | for (let index = 0; index < array.length; index++) { 9 | await callback(array[index], index, array) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './asyncForEach' 2 | export * from './getLintConfig' 3 | export * from './getProjectRoot' 4 | export * from './gremlinCharacters' 5 | export * from './isIgnored' 6 | export * from './listSasFiles' 7 | export * from './splitText' 8 | export * from './getIndicesOf' 9 | export * from './getHeaderLinesCount' 10 | export * from './getDataSectionsDetail' 11 | -------------------------------------------------------------------------------- /.sasjslint: -------------------------------------------------------------------------------- 1 | { 2 | "noTrailingSpaces": true, 3 | "noEncodedPasswords": true, 4 | "hasDoxygenHeader": true, 5 | "noSpacesInFileNames": true, 6 | "maxLineLength": 80, 7 | "lowerCaseFileNames": true, 8 | "noTabIndentation": true, 9 | "indentationMultiple": 2, 10 | "hasMacroNameInMend": true, 11 | "noNestedMacros": true, 12 | "hasMacroParentheses": true 13 | } -------------------------------------------------------------------------------- /src/utils/listSasFiles.ts: -------------------------------------------------------------------------------- 1 | import { listFilesInFolder } from '@sasjs/utils/file' 2 | 3 | /** 4 | * Fetches a list of .sas files in the given path. 5 | * @returns {Promise} resolves with an array of file names. 6 | */ 7 | export const listSasFiles = async (folderPath: string): Promise => { 8 | const files = await listFilesInFolder(folderPath) 9 | return files.filter((f) => f.endsWith('.sas')) 10 | } 11 | -------------------------------------------------------------------------------- /src/rules/file/index.ts: -------------------------------------------------------------------------------- 1 | export { hasDoxygenHeader } from './hasDoxygenHeader' 2 | export { hasMacroNameInMend } from './hasMacroNameInMend' 3 | export { hasMacroParentheses } from './hasMacroParentheses' 4 | export { lineEndings } from './lineEndings' 5 | export { noNestedMacros } from './noNestedMacros' 6 | export { strictMacroDefinition } from './strictMacroDefinition' 7 | export { hasRequiredMacroOptions } from './hasRequiredMacroOptions' 8 | -------------------------------------------------------------------------------- /checkNodeVersion.js: -------------------------------------------------------------------------------- 1 | const result = process.versions 2 | if (result && result.node) { 3 | if (parseInt(result.node) < 14) { 4 | console.log( 5 | '\x1b[31m%s\x1b[0m', 6 | `❌ Process failed due to Node Version,\nPlease install and use Node Version >= 14\nYour current Node Version is: ${result.node}` 7 | ) 8 | process.exit(1) 9 | } 10 | } else { 11 | console.log( 12 | '\x1b[31m%s\x1b[0m', 13 | 'Something went wrong while checking Node version' 14 | ) 15 | process.exit(1) 16 | } 17 | -------------------------------------------------------------------------------- /src/lint/lintText.ts: -------------------------------------------------------------------------------- 1 | import { getLintConfig } from '../utils/getLintConfig' 2 | import { processText } from './shared' 3 | 4 | /** 5 | * Analyses and produces a set of diagnostics for the given text content. 6 | * @param {string} text - the text content to be linted. 7 | * @returns {Diagnostic[]} array of diagnostic objects, each containing a warning, line number and column number. 8 | */ 9 | export const lintText = async (text: string) => { 10 | const config = await getLintConfig() 11 | return processText(text, config) 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/getColumnNumber.spec.ts: -------------------------------------------------------------------------------- 1 | import { getColumnNumber } from './getColumnNumber' 2 | 3 | describe('getColumnNumber', () => { 4 | it('should return the column number of the specified string within a line of text', () => { 5 | expect(getColumnNumber('foo bar', 'bar')).toEqual(5) 6 | }) 7 | 8 | it('should throw an error when the specified string is not found within the text', () => { 9 | expect(() => getColumnNumber('foo bar', 'baz')).toThrowError( 10 | "String 'baz' was not found in line 'foo bar'" 11 | ) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /src/Example File.sas: -------------------------------------------------------------------------------- 1 | 2 | 3 | %macro mf_getuniquelibref(prefix=mclib,maxtries=1000); 4 | %local x libref; 5 | %let x={SAS002}; 6 | %do x=0 %to &maxtries; 7 | %if %sysfunc(libref(&prefix&x)) ne 0 %then %do; 8 | %let libref=&prefix&x; 9 | %let rc=%sysfunc(libname(&libref,%sysfunc(pathname(work)))); 10 | %if &rc %then %put %sysfunc(sysmsg()); 11 | &prefix&x 12 | %*put &sysmacroname: Libref &libref assigned as WORK and returned; 13 | %return; 14 | %end; 15 | %end; 16 | %put unable to find available libref in range &prefix.0-&maxtries; 17 | %mend; 18 | 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "ES2018", 5 | "DOM", 6 | "ES2019.String" 7 | ], 8 | "target": "es6", 9 | "module": "commonjs", 10 | "downlevelIteration": true, 11 | "moduleResolution": "node", 12 | "esModuleInterop": true, 13 | "declaration": true, 14 | "outDir": "./build", 15 | "strict": true, 16 | "sourceMap": true 17 | }, 18 | "include": [ 19 | "src" 20 | ], 21 | "exclude": [ 22 | "node_modules", 23 | "**/*.spec.ts", 24 | "**/example.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/getIndicesOf.ts: -------------------------------------------------------------------------------- 1 | export const getIndicesOf = ( 2 | searchStr: string, 3 | str: string, 4 | caseSensitive: boolean = true 5 | ) => { 6 | const searchStrLen = searchStr.length 7 | if (searchStrLen === 0) { 8 | return [] 9 | } 10 | 11 | let startIndex = 0, 12 | index, 13 | indices = [] 14 | 15 | if (!caseSensitive) { 16 | str = str.toLowerCase() 17 | searchStr = searchStr.toLowerCase() 18 | } 19 | 20 | while ((index = str.indexOf(searchStr, startIndex)) > -1) { 21 | indices.push(index) 22 | startIndex = index + searchStrLen 23 | } 24 | 25 | return indices 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/asyncForEach.spec.ts: -------------------------------------------------------------------------------- 1 | import { asyncForEach } from './asyncForEach' 2 | 3 | describe('asyncForEach', () => { 4 | it('should execute the async callback for each item in the given array', async () => { 5 | const callback = jest.fn().mockImplementation(() => Promise.resolve()) 6 | const array = [1, 2, 3] 7 | 8 | await asyncForEach(array, callback) 9 | 10 | expect(callback.mock.calls.length).toEqual(3) 11 | expect(callback.mock.calls[0]).toEqual([1, 0, array]) 12 | expect(callback.mock.calls[1]).toEqual([2, 1, array]) 13 | expect(callback.mock.calls[2]).toEqual([3, 2, array]) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Issue 2 | 3 | Link any related issue(s) in this section. 4 | 5 | ## Intent 6 | 7 | What this PR intends to achieve. 8 | 9 | ## Implementation 10 | 11 | What code changes have been made to achieve the intent. 12 | 13 | ## Checks 14 | 15 | - [ ] Code is formatted correctly (`npm run lint:fix`). 16 | - [ ] Any new functionality has been unit tested. 17 | - [ ] All unit tests are passing (`npm test`). 18 | - [ ] All CI checks are green. 19 | - [ ] sasjslint-schema.json is updated with any new / changed functionality 20 | - [ ] JSDoc comments have been added or updated. 21 | - [ ] Reviewer is assigned. 22 | -------------------------------------------------------------------------------- /src/formatExample.ts: -------------------------------------------------------------------------------- 1 | import { formatText } from './format/formatText' 2 | import { lintText } from './lint' 3 | 4 | const content = `%put 'Hello'; 5 | %put 'World'; 6 | %macro somemacro() 7 | %put 'test'; 8 | %mend;\r\n` 9 | 10 | console.log(content) 11 | lintText(content).then((diagnostics) => { 12 | console.log('Before Formatting:') 13 | console.table(diagnostics) 14 | formatText(content).then((formattedText) => { 15 | lintText(formattedText).then((newDiagnostics) => { 16 | console.log('After Formatting:') 17 | console.log(formattedText) 18 | console.table(newDiagnostics) 19 | }) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/utils/getHeaderLinesCount.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig } from '../types' 2 | import { splitText } from './splitText' 3 | 4 | /** 5 | * This function returns the number of lines the header spans upon. 6 | * The file must start with "/*" and the header will finish with ⇙ 7 | */ 8 | export const getHeaderLinesCount = (text: string, config: LintConfig) => { 9 | let count = 0 10 | 11 | if (text.trimStart().startsWith('/*')) { 12 | const lines = splitText(text, config) 13 | 14 | for (const line of lines) { 15 | count++ 16 | if (line.match(/\*\//)) { 17 | break 18 | } 19 | } 20 | } 21 | 22 | return count 23 | } 24 | -------------------------------------------------------------------------------- /src/lint/lintProject.ts: -------------------------------------------------------------------------------- 1 | import { getProjectRoot } from '../utils/getProjectRoot' 2 | import { lintFolder } from './lintFolder' 3 | 4 | /** 5 | * Analyses and produces a set of diagnostics for the current project. 6 | * @returns {Promise>} Resolves with a map with array of diagnostic objects, each containing a warning, line number and column number, and grouped by file path. 7 | */ 8 | export const lintProject = async () => { 9 | const projectRoot = (await getProjectRoot()) || process.currentDir 10 | if (!projectRoot) { 11 | throw new Error('SASjs Project Root was not found.') 12 | } 13 | 14 | console.info(`Linting all .sas files under ${projectRoot}`) 15 | 16 | return await lintFolder(projectRoot) 17 | } 18 | -------------------------------------------------------------------------------- /.git-hooks/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | RED="\033[1;31m" 3 | GREEN="\033[1;32m" 4 | 5 | # Get the commit message (the parameter we're given is just the path to the 6 | # temporary file which holds the message). 7 | commit_message=$(cat "$1") 8 | 9 | if (echo "$commit_message" | grep -Eq "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z \-]+\))?!?: .+$") then 10 | echo "${GREEN} ✔ Commit message meets Conventional Commit standards" 11 | exit 0 12 | fi 13 | 14 | echo "${RED}❌ Commit message does not meet the Conventional Commit standard!" 15 | echo "An example of a valid message is:" 16 | echo " feat(login): add the 'remember me' button" 17 | echo "ℹ More details at: https://www.conventionalcommits.org/en/v1.0.0/#summary" 18 | exit 1 -------------------------------------------------------------------------------- /src/utils/getHeaderLinesCount.spec.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig } from '../types' 2 | import { getHeaderLinesCount } from './getHeaderLinesCount' 3 | import { DefaultLintConfiguration } from './getLintConfig' 4 | 5 | const sasCodeWithHeader = `/** 6 | @file 7 | @brief 8 |

SAS Macros

9 | **/ 10 | %put hello world; 11 | ` 12 | 13 | const sasCodeWithoutHeader = `%put hello world;` 14 | 15 | describe('getHeaderLinesCount', () => { 16 | it('should return the number of line header spans upon', () => { 17 | const config = new LintConfig(DefaultLintConfiguration) 18 | expect(getHeaderLinesCount(sasCodeWithHeader, config)).toEqual(5) 19 | expect(getHeaderLinesCount(sasCodeWithoutHeader, config)).toEqual(0) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/rules/line/noTabs.spec.ts: -------------------------------------------------------------------------------- 1 | import { Severity } from '../../types/Severity' 2 | import { noTabs } from './noTabs' 3 | 4 | describe('noTabs', () => { 5 | it('should return an empty array when the line is not indented with a tab', () => { 6 | const line = "%put 'hello';" 7 | expect(noTabs.test(line, 1)).toEqual([]) 8 | }) 9 | 10 | it('should return an array with a single diagnostic when the line is indented with a tab', () => { 11 | const line = "\t%put 'hello';" 12 | expect(noTabs.test(line, 1)).toEqual([ 13 | { 14 | message: 'Line contains a tab character (09x)', 15 | lineNumber: 1, 16 | startColumnNumber: 1, 17 | endColumnNumber: 2, 18 | severity: Severity.Warning 19 | } 20 | ]) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /src/utils/getProjectRoot.spec.ts: -------------------------------------------------------------------------------- 1 | import { getProjectRoot } from './getProjectRoot' 2 | import path from 'path' 3 | 4 | describe('getProjectRoot', () => { 5 | it('should return the current location if it contains the lint config file', async () => { 6 | const projectRoot = await getProjectRoot() 7 | 8 | expect(projectRoot).toEqual(process.cwd()) 9 | }) 10 | 11 | it('should return the parent folder if it contains the lint config file', async () => { 12 | const currentLocation = process.cwd() 13 | jest 14 | .spyOn(process, 'cwd') 15 | .mockImplementationOnce(() => 16 | path.join(currentLocation, 'folder', 'subfolder') 17 | ) 18 | const projectRoot = await getProjectRoot() 19 | 20 | expect(projectRoot).toEqual(currentLocation) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /src/format/formatProject.ts: -------------------------------------------------------------------------------- 1 | import { lintFolder } from '../lint/lintFolder' 2 | import { FormatResult } from '../types/FormatResult' 3 | import { getProjectRoot } from '../utils/getProjectRoot' 4 | import { formatFolder } from './formatFolder' 5 | 6 | /** 7 | * Automatically formats all SAS files in the current project. 8 | * @returns {Promise} Resolves successfully when all SAS files in the current project have been formatted. 9 | */ 10 | export const formatProject = async (): Promise => { 11 | const projectRoot = (await getProjectRoot()) || process.currentDir 12 | if (!projectRoot) { 13 | throw new Error('SASjs Project Root was not found.') 14 | } 15 | 16 | console.info(`Formatting all .sas files under ${projectRoot}`) 17 | 18 | return await formatFolder(projectRoot) 19 | } 20 | -------------------------------------------------------------------------------- /src/rules/line/noTrailingSpaces.spec.ts: -------------------------------------------------------------------------------- 1 | import { Severity } from '../../types/Severity' 2 | import { noTrailingSpaces } from './noTrailingSpaces' 3 | 4 | describe('noTrailingSpaces', () => { 5 | it('should return an empty array when the line has no trailing spaces', () => { 6 | const line = "%put 'hello';" 7 | expect(noTrailingSpaces.test(line, 1)).toEqual([]) 8 | }) 9 | 10 | it('should return an array with a single diagnostic when the line has trailing spaces', () => { 11 | const line = "%put 'hello'; " 12 | expect(noTrailingSpaces.test(line, 1)).toEqual([ 13 | { 14 | message: 'Line contains trailing spaces', 15 | lineNumber: 1, 16 | startColumnNumber: 14, 17 | endColumnNumber: 15, 18 | severity: Severity.Warning 19 | } 20 | ]) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: SASjs Lint Build and Publish 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | - name: Install Dependencies 18 | run: npm ci 19 | - name: Check Code Style 20 | run: npm run lint 21 | - name: Install rimraf 22 | run: npm i -g rimraf 23 | - name: Build Project 24 | run: npm run build 25 | - name: Semantic Release 26 | uses: cycjimmy/semantic-release-action@v3 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 29 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | -------------------------------------------------------------------------------- /src/utils/splitText.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig } from '../types/LintConfig' 2 | import { LineEndings } from '../types/LineEndings' 3 | 4 | /** 5 | * Splits the given content into a list of lines, regardless of CRLF or LF line endings. 6 | * @param {string} text - the text content to be split into lines. 7 | * @returns {string[]} an array of lines from the given text 8 | */ 9 | export const splitText = (text: string, config: LintConfig): string[] => { 10 | if (!text) return [] 11 | 12 | const expectedLineEndings = 13 | config.lineEndings === LineEndings.LF ? '\n' : '\r\n' 14 | 15 | const incorrectLineEndings = expectedLineEndings === '\n' ? '\r\n' : '\n' 16 | 17 | text = text.replace( 18 | new RegExp(incorrectLineEndings, 'g'), 19 | expectedLineEndings 20 | ) 21 | 22 | // splitting text on '\r\n' was causing some problem 23 | // as it was retaining carriage return at the end of each line 24 | // so, removed the carriage returns from text and splitted on line feed (lf) 25 | return text.replace(/\r/g, '').split(/\n/) 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/getProjectRoot.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import os from 'os' 3 | import { fileExists } from '@sasjs/utils/file' 4 | 5 | /** 6 | * Returns the absolute path to the location of the .sasjslint file. 7 | * Traverses the folder tree until the .sasjslint file is found. 8 | * @returns {Promise} the path to the folder containing the lint config. 9 | */ 10 | export async function getProjectRoot(): Promise { 11 | let root = '' 12 | let rootFound = false 13 | let i = 1 14 | let currentLocation = process.cwd() 15 | const homeDir = os.homedir() 16 | 17 | const maxLevels = currentLocation.split(path.sep).length 18 | 19 | while (i <= maxLevels && !rootFound && currentLocation !== homeDir) { 20 | const isRoot = await fileExists(path.join(currentLocation, '.sasjslint')) 21 | 22 | if (isRoot) { 23 | rootFound = true 24 | root = currentLocation 25 | 26 | break 27 | } else { 28 | currentLocation = path.join(currentLocation, '..') 29 | i++ 30 | } 31 | } 32 | 33 | return root 34 | } 35 | -------------------------------------------------------------------------------- /src/rules/line/noGremlins.spec.ts: -------------------------------------------------------------------------------- 1 | import { noGremlins, charFromHex } from './noGremlins' 2 | import { LintConfig } from '../../types' 3 | 4 | describe('noTabs', () => { 5 | it('should return an empty array when the line does not have any gremlin', () => { 6 | const line = "%put 'hello';" 7 | expect(noGremlins.test(line, 1)).toEqual([]) 8 | }) 9 | 10 | it('should return a diagnostic array when the line contains gremlins', () => { 11 | const line = `${charFromHex('0x0080')} ${charFromHex( 12 | '0x3000' 13 | )} %put 'hello';` 14 | const diagnostics = noGremlins.test(line, 1) 15 | expect(diagnostics.length).toEqual(2) 16 | }) 17 | 18 | it('should return an empty array when the line contains gremlins but those gremlins are allowed', () => { 19 | const config = new LintConfig({ allowedGremlins: ['0x0080', '0x3000'] }) 20 | const line = `${charFromHex('0x0080')} ${charFromHex( 21 | '0x3000' 22 | )} %put 'hello';` 23 | const diagnostics = noGremlins.test(line, 1, config) 24 | expect(diagnostics.length).toEqual(0) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/utils/getLintConfig.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fileModule from '@sasjs/utils/file' 2 | import { LintConfig } from '../types/LintConfig' 3 | import { getLintConfig } from './getLintConfig' 4 | 5 | const expectedFileLintRulesCount = 5 6 | const expectedLineLintRulesCount = 6 7 | const expectedPathLintRulesCount = 2 8 | 9 | describe('getLintConfig', () => { 10 | it('should get the lint config', async () => { 11 | const config = await getLintConfig() 12 | 13 | expect(config).toBeInstanceOf(LintConfig) 14 | }) 15 | 16 | it('should get the default config when a .sasjslint file is unavailable', async () => { 17 | jest 18 | .spyOn(fileModule, 'readFile') 19 | .mockImplementationOnce(() => Promise.reject()) 20 | 21 | const config = await getLintConfig() 22 | 23 | expect(config).toBeInstanceOf(LintConfig) 24 | expect(config.fileLintRules.length).toEqual(expectedFileLintRulesCount) 25 | expect(config.lineLintRules.length).toEqual(expectedLineLintRulesCount) 26 | expect(config.pathLintRules.length).toEqual(expectedPathLintRulesCount) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: SASjs Lint Build 5 | 6 | on: 7 | push: 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [lts/*] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: npm 25 | - name: Install Dependencies 26 | run: npm ci 27 | - name: Check Code Style 28 | run: npm run lint 29 | - name: Run Unit Tests 30 | run: npm test 31 | - name: Install rimraf 32 | run: npm i -g rimraf 33 | - name: Build Package 34 | run: npm run package:lib 35 | env: 36 | CI: true 37 | -------------------------------------------------------------------------------- /src/rules/path/noSpacesInFileNames.spec.ts: -------------------------------------------------------------------------------- 1 | import { Severity } from '../../types/Severity' 2 | import { noSpacesInFileNames } from './noSpacesInFileNames' 3 | 4 | describe('noSpacesInFileNames', () => { 5 | it('should return an empty array when the file name has no spaces', () => { 6 | const filePath = '/code/sas/my_sas_file.sas' 7 | expect(noSpacesInFileNames.test(filePath)).toEqual([]) 8 | }) 9 | 10 | it('should return an empty array when the file name has no spaces, even if the containing folder has spaces', () => { 11 | const filePath = '/code/sas projects/my_sas_file.sas' 12 | expect(noSpacesInFileNames.test(filePath)).toEqual([]) 13 | }) 14 | 15 | it('should return an array with a single diagnostic when the file name has spaces', () => { 16 | const filePath = '/code/sas/my sas file.sas' 17 | expect(noSpacesInFileNames.test(filePath)).toEqual([ 18 | { 19 | message: 'File name contains spaces', 20 | lineNumber: 1, 21 | startColumnNumber: 1, 22 | endColumnNumber: 1, 23 | severity: Severity.Warning 24 | } 25 | ]) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /src/rules/path/noSpacesInFileNames.ts: -------------------------------------------------------------------------------- 1 | import { PathLintRule } from '../../types/LintRule' 2 | import { LintRuleType } from '../../types/LintRuleType' 3 | import { Severity } from '../../types/Severity' 4 | import path from 'path' 5 | import { LintConfig } from '../../types' 6 | 7 | const name = 'noSpacesInFileNames' 8 | const description = 'Enforce the absence of spaces within file names.' 9 | const message = 'File name contains spaces' 10 | 11 | const test = (value: string, config?: LintConfig) => { 12 | const severity = config?.severityLevel[name] || Severity.Warning 13 | const fileName = path.basename(value) 14 | 15 | if (fileName.includes(' ')) { 16 | return [ 17 | { 18 | message, 19 | lineNumber: 1, 20 | startColumnNumber: 1, 21 | endColumnNumber: 1, 22 | severity 23 | } 24 | ] 25 | } 26 | return [] 27 | } 28 | 29 | /** 30 | * Lint rule that checks for the absence of spaces in a given file name. 31 | */ 32 | export const noSpacesInFileNames: PathLintRule = { 33 | type: LintRuleType.Path, 34 | name, 35 | description, 36 | message, 37 | test 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 SASjs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lint/lintFile.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from '@sasjs/utils/file' 2 | import { Diagnostic, LintConfig } from '../types' 3 | import { getLintConfig, isIgnored } from '../utils' 4 | import { processFile, processText } from './shared' 5 | 6 | /** 7 | * Analyses and produces a set of diagnostics for the file at the given path. 8 | * @param {string} filePath - the path to the file to be linted. 9 | * @param {LintConfig} configuration - an optional configuration. When not passed in, this is read from the .sasjslint file. 10 | * @returns {Promise} array of diagnostic objects, each containing a warning, line number and column number. 11 | */ 12 | export const lintFile = async ( 13 | filePath: string, 14 | configuration?: LintConfig 15 | ): Promise => { 16 | if (await isIgnored(filePath, configuration)) return [] 17 | 18 | const config = configuration || (await getLintConfig()) 19 | const text = await readFile(filePath) 20 | 21 | const fileDiagnostics = processFile(filePath, config) 22 | const textDiagnostics = processText(text, config) 23 | 24 | return [...fileDiagnostics, ...textDiagnostics] 25 | } 26 | -------------------------------------------------------------------------------- /src/rules/path/lowerCaseFileNames.ts: -------------------------------------------------------------------------------- 1 | import { PathLintRule } from '../../types/LintRule' 2 | import { LintRuleType } from '../../types/LintRuleType' 3 | import { Severity } from '../../types/Severity' 4 | import path from 'path' 5 | import { LintConfig } from '../../types' 6 | 7 | const name = 'lowerCaseFileNames' 8 | const description = 'Enforce the use of lower case file names.' 9 | const message = 'File name contains uppercase characters' 10 | 11 | const test = (value: string, config?: LintConfig) => { 12 | const severity = config?.severityLevel[name] || Severity.Warning 13 | const fileName = path.basename(value) 14 | 15 | if (fileName.toLocaleLowerCase() === fileName) return [] 16 | 17 | return [ 18 | { 19 | message, 20 | lineNumber: 1, 21 | startColumnNumber: 1, 22 | endColumnNumber: 1, 23 | severity 24 | } 25 | ] 26 | } 27 | 28 | /** 29 | * Lint rule that checks for the absence of uppercase characters in a given file name. 30 | */ 31 | export const lowerCaseFileNames: PathLintRule = { 32 | type: LintRuleType.Path, 33 | name, 34 | description, 35 | message, 36 | test 37 | } 38 | -------------------------------------------------------------------------------- /src/rules/line/noTabs.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig } from '../../types' 2 | import { LineLintRule } from '../../types/LintRule' 3 | import { LintRuleType } from '../../types/LintRuleType' 4 | import { Severity } from '../../types/Severity' 5 | import { getIndicesOf } from '../../utils' 6 | 7 | const name = 'noTabs' 8 | const alias = 'noTabIndentation' 9 | const description = 'Disallow indenting with tabs.' 10 | const message = 'Line contains a tab character (09x)' 11 | 12 | const test = (value: string, lineNumber: number, config?: LintConfig) => { 13 | const severity = 14 | config?.severityLevel[name] || 15 | config?.severityLevel[alias] || 16 | Severity.Warning 17 | 18 | const indices = getIndicesOf('\t', value) 19 | 20 | return indices.map((index) => ({ 21 | message, 22 | lineNumber, 23 | startColumnNumber: index + 1, 24 | endColumnNumber: index + 2, 25 | severity 26 | })) 27 | } 28 | 29 | /** 30 | * Lint rule that checks if a given line of text is indented with a tab. 31 | */ 32 | export const noTabs: LineLintRule = { 33 | type: LintRuleType.Line, 34 | name, 35 | description, 36 | message, 37 | test 38 | } 39 | -------------------------------------------------------------------------------- /src/rules/line/noTrailingSpaces.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig } from '../../types' 2 | import { LineLintRule } from '../../types/LintRule' 3 | import { LintRuleType } from '../../types/LintRuleType' 4 | import { Severity } from '../../types/Severity' 5 | 6 | const name = 'noTrailingSpaces' 7 | const description = 'Disallow trailing spaces on lines.' 8 | const message = 'Line contains trailing spaces' 9 | 10 | const test = (value: string, lineNumber: number, config?: LintConfig) => { 11 | const severity = config?.severityLevel[name] || Severity.Warning 12 | 13 | return value.trimEnd() === value 14 | ? [] 15 | : [ 16 | { 17 | message, 18 | lineNumber, 19 | startColumnNumber: value.trimEnd().length + 1, 20 | endColumnNumber: value.length, 21 | severity 22 | } 23 | ] 24 | } 25 | 26 | const fix = (value: string) => value.trimEnd() 27 | 28 | /** 29 | * Lint rule that checks for the presence of trailing space(s) in a given line of text. 30 | */ 31 | export const noTrailingSpaces: LineLintRule = { 32 | type: LintRuleType.Line, 33 | name, 34 | description, 35 | message, 36 | test, 37 | fix 38 | } 39 | -------------------------------------------------------------------------------- /src/rules/path/lowerCaseFileNames.spec.ts: -------------------------------------------------------------------------------- 1 | import { Severity } from '../../types/Severity' 2 | import { lowerCaseFileNames } from './lowerCaseFileNames' 3 | 4 | describe('lowerCaseFileNames', () => { 5 | it('should return an empty array when the file name has no uppercase characters', () => { 6 | const filePath = '/code/sas/my_sas_file.sas' 7 | expect(lowerCaseFileNames.test(filePath)).toEqual([]) 8 | }) 9 | 10 | it('should return an empty array when the file name has no uppercase characters, even if the containing folder has uppercase characters', () => { 11 | const filePath = '/code/SAS Projects/my_sas_file.sas' 12 | expect(lowerCaseFileNames.test(filePath)).toEqual([]) 13 | }) 14 | 15 | it('should return an array with a single diagnostic when the file name has uppercase characters', () => { 16 | const filePath = '/code/sas/my SAS file.sas' 17 | expect(lowerCaseFileNames.test(filePath)).toEqual([ 18 | { 19 | message: 'File name contains uppercase characters', 20 | lineNumber: 1, 21 | startColumnNumber: 1, 22 | endColumnNumber: 1, 23 | severity: Severity.Warning 24 | } 25 | ]) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /src/rules/line/noEncodedPasswords.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig } from '../../types' 2 | import { LineLintRule } from '../../types/LintRule' 3 | import { LintRuleType } from '../../types/LintRuleType' 4 | import { Severity } from '../../types/Severity' 5 | 6 | const name = 'noEncodedPasswords' 7 | const description = 'Disallow encoded passwords in SAS code.' 8 | const message = 'Line contains encoded password' 9 | 10 | const test = (value: string, lineNumber: number, config?: LintConfig) => { 11 | const severity = config?.severityLevel[name] || Severity.Error 12 | const regex = new RegExp(/{sas(\d{2,4}|enc)}[^;"'\s]*/, 'gi') 13 | const matches = value.match(regex) 14 | if (!matches || !matches.length) return [] 15 | return matches.map((match) => ({ 16 | message, 17 | lineNumber, 18 | startColumnNumber: value.indexOf(match) + 1, 19 | endColumnNumber: value.indexOf(match) + match.length + 1, 20 | severity 21 | })) 22 | } 23 | 24 | /** 25 | * Lint rule that checks for the presence of encoded password(s) in a given line of text. 26 | */ 27 | export const noEncodedPasswords: LineLintRule = { 28 | type: LintRuleType.Line, 29 | name, 30 | description, 31 | message, 32 | test 33 | } 34 | -------------------------------------------------------------------------------- /src/format/shared.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig } from '../types' 2 | import { LineEndings } from '../types/LineEndings' 3 | import { splitText } from '../utils/splitText' 4 | 5 | export const processText = (text: string, config: LintConfig) => { 6 | const processedText = processContent(config, text) 7 | const lines = splitText(processedText, config) 8 | const formattedLines = lines.map((line) => { 9 | return processLine(config, line) 10 | }) 11 | 12 | const configuredLineEnding = 13 | config.lineEndings === LineEndings.LF ? '\n' : '\r\n' 14 | return formattedLines.join(configuredLineEnding) 15 | } 16 | 17 | const processContent = (config: LintConfig, content: string): string => { 18 | let processedContent = content 19 | config.fileLintRules 20 | .filter((r) => !!r.fix) 21 | .forEach((rule) => { 22 | processedContent = rule.fix!(processedContent, config) 23 | }) 24 | 25 | return processedContent 26 | } 27 | 28 | export const processLine = (config: LintConfig, line: string): string => { 29 | let processedLine = line 30 | config.lineLintRules 31 | .filter((r) => !!r.fix) 32 | .forEach((rule) => { 33 | processedLine = rule.fix!(processedLine, config) 34 | }) 35 | 36 | return processedLine 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/isIgnored.ts: -------------------------------------------------------------------------------- 1 | import { fileExists, readFile } from '@sasjs/utils' 2 | import path from 'path' 3 | import ignore from 'ignore' 4 | import { getLintConfig, getProjectRoot } from '.' 5 | import { LintConfig } from '../types' 6 | 7 | /** 8 | * A function to check if file/folder path matches any pattern from .gitignore or ignoreList (.sasjsLint) 9 | * 10 | * @param {string} fPath - absolute path of file or folder 11 | * @returns {Promise} true if path matches the patterns from .gitignore file otherwise false 12 | */ 13 | export const isIgnored = async ( 14 | fPath: string, 15 | configuration?: LintConfig 16 | ): Promise => { 17 | const config = configuration || (await getLintConfig()) 18 | const projectRoot = await getProjectRoot() 19 | const gitIgnoreFilePath = path.join(projectRoot, '.gitignore') 20 | const rootPath = projectRoot + path.sep 21 | const relativePath = fPath.replace(rootPath, '') 22 | 23 | if (fPath === projectRoot) return false 24 | 25 | let gitIgnoreFileContent = '' 26 | 27 | if (await fileExists(gitIgnoreFilePath)) 28 | gitIgnoreFileContent = await readFile(gitIgnoreFilePath) 29 | 30 | return ignore() 31 | .add(gitIgnoreFileContent) 32 | .add(config.ignoreList) 33 | .ignores(relativePath) 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/splitText.spec.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig } from '../types' 2 | import { splitText } from './splitText' 3 | 4 | describe('splitText', () => { 5 | const config = new LintConfig({ 6 | noTrailingSpaces: true, 7 | noEncodedPasswords: true, 8 | hasDoxygenHeader: true, 9 | noSpacesInFileNames: true, 10 | maxLineLength: 80, 11 | lowerCaseFileNames: true, 12 | noTabIndentation: true, 13 | indentationMultiple: 2, 14 | hasMacroNameInMend: true, 15 | noNestedMacros: true, 16 | hasMacroParentheses: true, 17 | lineEndings: 'lf' 18 | }) 19 | 20 | it('should return an empty array when text is falsy', () => { 21 | const lines = splitText('', config) 22 | 23 | expect(lines.length).toEqual(0) 24 | }) 25 | 26 | it('should return an array of lines from text', () => { 27 | const lines = splitText(`line 1\nline 2`, config) 28 | 29 | expect(lines.length).toEqual(2) 30 | expect(lines[0]).toEqual('line 1') 31 | expect(lines[1]).toEqual('line 2') 32 | }) 33 | 34 | it('should work with CRLF line endings', () => { 35 | const lines = splitText(`line 1\r\nline 2`, config) 36 | 37 | expect(lines.length).toEqual(2) 38 | expect(lines[0]).toEqual('line 1') 39 | expect(lines[1]).toEqual('line 2') 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/types/LintRule.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic } from './Diagnostic' 2 | import { LintConfig } from './LintConfig' 3 | import { LintRuleType } from './LintRuleType' 4 | 5 | /** 6 | * A lint rule is defined by a type, name, description, message text and a test function. 7 | * The test function produces a set of diagnostics when executed. 8 | */ 9 | export interface LintRule { 10 | type: LintRuleType 11 | name: string 12 | description: string 13 | message: string 14 | } 15 | 16 | export interface LineLintRuleOptions { 17 | isHeaderLine?: boolean 18 | isDataLine?: boolean 19 | } 20 | 21 | /** 22 | * A LineLintRule is run once per line of text. 23 | */ 24 | export interface LineLintRule extends LintRule { 25 | type: LintRuleType.Line 26 | test: ( 27 | value: string, 28 | lineNumber: number, 29 | config?: LintConfig, 30 | options?: LineLintRuleOptions 31 | ) => Diagnostic[] 32 | fix?: (value: string, config?: LintConfig) => string 33 | } 34 | 35 | /** 36 | * A FileLintRule is run once per file. 37 | */ 38 | export interface FileLintRule extends LintRule { 39 | type: LintRuleType.File 40 | test: (value: string, config?: LintConfig) => Diagnostic[] 41 | fix?: (value: string, config?: LintConfig) => string 42 | } 43 | 44 | /** 45 | * A PathLintRule is run once per file. 46 | */ 47 | export interface PathLintRule extends LintRule { 48 | type: LintRuleType.Path 49 | test: (value: string, config?: LintConfig) => Diagnostic[] 50 | } 51 | -------------------------------------------------------------------------------- /src/rules/line/indentationMultiple.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig } from '../../types' 2 | import { LineLintRule } from '../../types/LintRule' 3 | import { LintRuleType } from '../../types/LintRuleType' 4 | import { Severity } from '../../types/Severity' 5 | 6 | const name = 'indentationMultiple' 7 | const description = 'Ensure indentation by a multiple of the configured number.' 8 | const message = 'Line has incorrect indentation' 9 | 10 | const test = (value: string, lineNumber: number, config?: LintConfig) => { 11 | if (!value.startsWith(' ')) return [] 12 | 13 | const severity = config?.severityLevel[name] || Severity.Warning 14 | const indentationMultiple = isNaN(config?.indentationMultiple as number) 15 | ? 2 16 | : config!.indentationMultiple 17 | 18 | if (indentationMultiple === 0) return [] 19 | const numberOfSpaces = value.search(/\S|$/) 20 | if (numberOfSpaces % indentationMultiple! === 0) return [] 21 | return [ 22 | { 23 | message: `${message} - ${numberOfSpaces} ${ 24 | numberOfSpaces === 1 ? 'space' : 'spaces' 25 | }`, 26 | lineNumber, 27 | startColumnNumber: 1, 28 | endColumnNumber: 1, 29 | severity 30 | } 31 | ] 32 | } 33 | 34 | /** 35 | * Lint rule that checks if a line is indented by a multiple of the configured indentation multiple. 36 | */ 37 | export const indentationMultiple: LineLintRule = { 38 | type: LintRuleType.Line, 39 | name, 40 | description, 41 | message, 42 | test 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/trimComments.ts: -------------------------------------------------------------------------------- 1 | export const trimComments = ( 2 | statement: string, 3 | commentStarted: boolean = false, 4 | trimEnd: boolean = false 5 | ): { statement: string; commentStarted: boolean } => { 6 | let trimmed = trimEnd ? (statement || '').trimEnd() : (statement || '').trim() 7 | 8 | if (commentStarted || trimmed.startsWith('/*')) { 9 | const parts = trimmed.startsWith('/*') 10 | ? trimmed.slice(2).split('*/') 11 | : trimmed.split('*/') 12 | if (parts.length === 2) { 13 | return { 14 | statement: (parts.pop() as string).trim(), 15 | commentStarted: false 16 | } 17 | } else if (parts.length > 2) { 18 | parts.shift() 19 | return trimComments(parts.join('*/'), false) 20 | } else { 21 | return { statement: '', commentStarted: true } 22 | } 23 | } else if (trimmed.includes('/*')) { 24 | const statementBeforeCommentStarts = trimmed.slice(0, trimmed.indexOf('/*')) 25 | trimmed = trimmed.slice(trimmed.indexOf('/*') + 2) 26 | const remainingStatement = trimmed.slice(trimmed.indexOf('*/') + 2) 27 | 28 | const result = trimComments(remainingStatement, false, true) 29 | const completeStatement = statementBeforeCommentStarts + result.statement 30 | return { 31 | statement: trimEnd 32 | ? completeStatement.trimEnd() 33 | : completeStatement.trim(), 34 | commentStarted: result.commentStarted 35 | } 36 | } 37 | return { statement: trimmed, commentStarted: false } 38 | } 39 | -------------------------------------------------------------------------------- /src/rules/line/maxLineLength.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig } from '../../types' 2 | import { LineLintRule, LineLintRuleOptions } from '../../types/LintRule' 3 | import { LintRuleType } from '../../types/LintRuleType' 4 | import { Severity } from '../../types/Severity' 5 | import { DefaultLintConfiguration } from '../../utils' 6 | 7 | const name = 'maxLineLength' 8 | const description = 'Restrict lines to the specified length.' 9 | const message = 'Line exceeds maximum length' 10 | 11 | const test = ( 12 | value: string, 13 | lineNumber: number, 14 | config?: LintConfig, 15 | options?: LineLintRuleOptions 16 | ) => { 17 | const severity = config?.severityLevel[name] || Severity.Warning 18 | let maxLineLength = DefaultLintConfiguration.maxLineLength 19 | 20 | if (config) { 21 | if (options?.isHeaderLine) { 22 | maxLineLength = Math.max(config.maxLineLength, config.maxHeaderLineLength) 23 | } else if (options?.isDataLine) { 24 | maxLineLength = Math.max(config.maxLineLength, config.maxDataLineLength) 25 | } else { 26 | maxLineLength = config.maxLineLength 27 | } 28 | } 29 | 30 | if (value.length <= maxLineLength) return [] 31 | return [ 32 | { 33 | message: `${message} by ${value.length - maxLineLength} characters`, 34 | lineNumber, 35 | startColumnNumber: 1, 36 | endColumnNumber: 1, 37 | severity 38 | } 39 | ] 40 | } 41 | 42 | /** 43 | * Lint rule that checks if a line has exceeded the configured maximum length. 44 | */ 45 | export const maxLineLength: LineLintRule = { 46 | type: LintRuleType.Line, 47 | name, 48 | description, 49 | message, 50 | test 51 | } 52 | -------------------------------------------------------------------------------- /src/format/formatText.spec.ts: -------------------------------------------------------------------------------- 1 | import { formatText } from './formatText' 2 | import * as getLintConfigModule from '../utils/getLintConfig' 3 | import { LintConfig } from '../types' 4 | jest.mock('../utils/getLintConfig') 5 | 6 | describe('formatText', () => { 7 | it('should format the given text based on configured rules', async () => { 8 | jest 9 | .spyOn(getLintConfigModule, 'getLintConfig') 10 | .mockImplementationOnce(() => 11 | Promise.resolve( 12 | new LintConfig(getLintConfigModule.DefaultLintConfiguration) 13 | ) 14 | ) 15 | const text = `%macro test; 16 | %put 'hello';\r\n%mend; ` 17 | 18 | const expectedOutput = `/** 19 | @file 20 | @brief 21 |

SAS Macros

22 | **/\n%macro test; 23 | %put 'hello';\n%mend test;` 24 | 25 | const output = await formatText(text) 26 | 27 | expect(output).toEqual(expectedOutput) 28 | }) 29 | 30 | it('should use CRLF line endings when configured', async () => { 31 | jest 32 | .spyOn(getLintConfigModule, 'getLintConfig') 33 | .mockImplementationOnce(() => 34 | Promise.resolve( 35 | new LintConfig({ 36 | ...getLintConfigModule.DefaultLintConfiguration, 37 | lineEndings: 'crlf' 38 | }) 39 | ) 40 | ) 41 | const text = `%macro test;\n %put 'hello';\r\n%mend; ` 42 | 43 | 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;` 44 | 45 | const output = await formatText(text) 46 | 47 | expect(output).toEqual(expectedOutput) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /src/rules/file/hasRequiredMacroOptions.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic, LintConfig, Macro, Severity } from '../../types' 2 | import { FileLintRule } from '../../types/LintRule' 3 | import { LintRuleType } from '../../types/LintRuleType' 4 | import { parseMacros } from '../../utils/parseMacros' 5 | 6 | const name = 'hasRequiredMacroOptions' 7 | const description = 'Enforce required macro options' 8 | const message = 'Macro defined without required options' 9 | 10 | const processOptions = ( 11 | macro: Macro, 12 | diagnostics: Diagnostic[], 13 | config?: LintConfig 14 | ): void => { 15 | const optionsPresent = macro.declaration.split('/')?.[1]?.trim() ?? '' 16 | const severity = config?.severityLevel[name] || Severity.Warning 17 | 18 | config?.requiredMacroOptions.forEach((option) => { 19 | if (!optionsPresent.includes(option)) { 20 | diagnostics.push({ 21 | message: `Macro '${macro.name}' does not contain the required option '${option}'`, 22 | lineNumber: macro.startLineNumbers[0], 23 | startColumnNumber: 0, 24 | endColumnNumber: 0, 25 | severity 26 | }) 27 | } 28 | }) 29 | } 30 | 31 | const test = (value: string, config?: LintConfig) => { 32 | const diagnostics: Diagnostic[] = [] 33 | 34 | const macros = parseMacros(value, config) 35 | 36 | macros.forEach((macro) => { 37 | processOptions(macro, diagnostics, config) 38 | }) 39 | 40 | return diagnostics 41 | } 42 | 43 | /** 44 | * Lint rule that checks if a macro has the required options 45 | */ 46 | export const hasRequiredMacroOptions: FileLintRule = { 47 | type: LintRuleType.File, 48 | name, 49 | description, 50 | message, 51 | test 52 | } 53 | -------------------------------------------------------------------------------- /src/lintExample.ts: -------------------------------------------------------------------------------- 1 | import { lintFile, lintText } from './lint' 2 | import path from 'path' 3 | 4 | /** 5 | * Example which tests a piece of text with all known violations. 6 | */ 7 | 8 | const text = `/** 9 | @file 10 | @brief Returns an unused libref 11 | @details Use as follows: 12 | 13 | libname mclib0 (work); 14 | libname mclib1 (work); 15 | libname mclib2 (work); 16 | 17 | %let libref=%mf_getuniquelibref({SAS001}); 18 | %put &=libref; 19 | 20 | which returns: 21 | 22 | > mclib3 23 | 24 | @param prefix= first part of libref. Remember that librefs can only be 8 characters, 25 | so a 7 letter prefix would mean that maxtries should be 10. 26 | @param maxtries= the last part of the libref. Provide an integer value. 27 | 28 | @version 9.2 29 | @author Allan Bowe 30 | **/ 31 | 32 | 33 | %macro mf_getuniquelibref(prefix=mclib,maxtries=1000); 34 | %local x libref; 35 | %let x={SAS002}; 36 | %do x=0 %to &maxtries; 37 | %if %sysfunc(libref(&prefix&x)) ne 0 %then %do; 38 | %let libref=&prefix&x; 39 | %let rc=%sysfunc(libname(&libref,%sysfunc(pathname(work)))); 40 | %if &rc %then %put %sysfunc(sysmsg()); 41 | &prefix&x 42 | %*put &sysmacroname: Libref &libref assigned as WORK and returned; 43 | %return; 44 | %end; 45 | %end; 46 | %put unable to find available libref in range &prefix.0-&maxtries; 47 | %mend; 48 | ` 49 | 50 | lintText(text).then((diagnostics) => { 51 | console.log('Text lint results:') 52 | console.table(diagnostics) 53 | }) 54 | 55 | lintFile(path.join(__dirname, 'Example File.sas')).then((diagnostics) => { 56 | console.log('File lint results:') 57 | console.table(diagnostics) 58 | }) 59 | -------------------------------------------------------------------------------- /src/format/formatFile.ts: -------------------------------------------------------------------------------- 1 | import { createFile, readFile } from '@sasjs/utils/file' 2 | import { lintFile } from '../lint' 3 | import { FormatResult } from '../types' 4 | import { LintConfig } from '../types/LintConfig' 5 | import { getLintConfig } from '../utils/getLintConfig' 6 | import { processText } from './shared' 7 | 8 | /** 9 | * Applies automatic formatting to the file at the given path. 10 | * @param {string} filePath - the path to the file to be formatted. 11 | * @param {LintConfig} configuration - an optional configuration. When not passed in, this is read from the .sasjslint file. 12 | * @returns {Promise} Resolves successfully when the file has been formatted. 13 | */ 14 | export const formatFile = async ( 15 | filePath: string, 16 | configuration?: LintConfig 17 | ): Promise => { 18 | const config = configuration || (await getLintConfig()) 19 | const diagnosticsBeforeFormat = await lintFile(filePath, config) 20 | const diagnosticsCountBeforeFormat = diagnosticsBeforeFormat.length 21 | 22 | const text = await readFile(filePath) 23 | 24 | const formattedText = processText(text, config) 25 | 26 | await createFile(filePath, formattedText) 27 | 28 | const diagnosticsAfterFormat = await lintFile(filePath, config) 29 | const diagnosticsCountAfterFormat = diagnosticsAfterFormat.length 30 | 31 | const fixedDiagnosticsCount = 32 | diagnosticsCountBeforeFormat - diagnosticsCountAfterFormat 33 | 34 | const updatedFilePaths: string[] = [] 35 | 36 | if (fixedDiagnosticsCount) { 37 | updatedFilePaths.push(filePath) 38 | } 39 | 40 | return { 41 | updatedFilePaths, 42 | fixedDiagnosticsCount, 43 | unfixedDiagnostics: diagnosticsAfterFormat 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sasjs/lint", 3 | "description": "Linting and formatting for SAS code", 4 | "scripts": { 5 | "test": "jest --coverage", 6 | "build": "npx rimraf build && tsc", 7 | "preinstall": "node checkNodeVersion", 8 | "prebuild": "node checkNodeVersion", 9 | "prepublishOnly": "cp -r ./build/* . && rm -rf ./build && rm -rf ./src && rm tsconfig.json", 10 | "postpublish": "git clean -fd", 11 | "package:lib": "npm run build && cp ./package.json ./checkNodeVersion.js build && cp README.md build && cd build && npm version \"5.0.0\" && npm pack", 12 | "lint:fix": "npx prettier --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\"", 13 | "lint": "npx prettier --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\"", 14 | "prepare": "git rev-parse --git-dir && git config core.hooksPath ./.git-hooks || true" 15 | }, 16 | "publishConfig": { 17 | "access": "public" 18 | }, 19 | "release": { 20 | "branches": [ 21 | "main" 22 | ] 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/sasjs/lint.git" 27 | }, 28 | "keywords": [ 29 | "sas", 30 | "SASjs", 31 | "viya", 32 | "lint", 33 | "formatting" 34 | ], 35 | "author": "", 36 | "license": "MIT", 37 | "bugs": { 38 | "url": "https://github.com/sasjs/lint/issues" 39 | }, 40 | "homepage": "https://github.com/sasjs/lint#readme", 41 | "devDependencies": { 42 | "@types/jest": "29.2.5", 43 | "@types/node": "18.11.18", 44 | "jest": "29.3.1", 45 | "ts-jest": "29.0.3", 46 | "typescript": "^4.3.2" 47 | }, 48 | "dependencies": { 49 | "@sasjs/utils": "3.5.2", 50 | "ignore": "5.2.4" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/lint/shared.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig, Diagnostic, LineLintRuleOptions } from '../types' 2 | import { getHeaderLinesCount, splitText } from '../utils' 3 | import { checkIsDataLine, getDataSectionsDetail } from '../utils' 4 | 5 | export const processText = (text: string, config: LintConfig) => { 6 | const lines = splitText(text, config) 7 | const headerLinesCount = getHeaderLinesCount(text, config) 8 | const dataSections = getDataSectionsDetail(text, config) 9 | const diagnostics: Diagnostic[] = [] 10 | diagnostics.push(...processContent(config, text)) 11 | lines.forEach((line, index) => { 12 | const isHeaderLine = index + 1 <= headerLinesCount 13 | const isDataLine = checkIsDataLine(dataSections, index) 14 | diagnostics.push( 15 | ...processLine(config, line, index + 1, { isHeaderLine, isDataLine }) 16 | ) 17 | }) 18 | 19 | return diagnostics 20 | } 21 | 22 | export const processFile = ( 23 | filePath: string, 24 | config: LintConfig 25 | ): Diagnostic[] => { 26 | const diagnostics: Diagnostic[] = [] 27 | config.pathLintRules.forEach((rule) => { 28 | diagnostics.push(...rule.test(filePath, config)) 29 | }) 30 | 31 | return diagnostics 32 | } 33 | 34 | const processContent = (config: LintConfig, content: string): Diagnostic[] => { 35 | const diagnostics: Diagnostic[] = [] 36 | config.fileLintRules.forEach((rule) => { 37 | diagnostics.push(...rule.test(content, config)) 38 | }) 39 | 40 | return diagnostics 41 | } 42 | 43 | export const processLine = ( 44 | config: LintConfig, 45 | line: string, 46 | lineNumber: number, 47 | options: LineLintRuleOptions 48 | ): Diagnostic[] => { 49 | const diagnostics: Diagnostic[] = [] 50 | config.lineLintRules.forEach((rule) => { 51 | diagnostics.push(...rule.test(line, lineNumber, config, options)) 52 | }) 53 | 54 | return diagnostics 55 | } 56 | -------------------------------------------------------------------------------- /src/utils/getDataSectionsDetail.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig } from '../types' 2 | import { splitText } from './splitText' 3 | 4 | interface DataSectionsDetail { 5 | start: number 6 | end: number 7 | } 8 | 9 | export const getDataSectionsDetail = (text: string, config: LintConfig) => { 10 | const dataSections: DataSectionsDetail[] = [] 11 | const lines = splitText(text, config) 12 | 13 | const dataSectionStartRegex1 = new RegExp( 14 | '^(datalines;)|(cards;)|(parmcards;)' 15 | ) 16 | const dataSectionEndRegex1 = new RegExp(';') 17 | const dataSectionStartRegex2 = new RegExp( 18 | '^(datalines4;)|(cards4;)|(parmcards4;)' 19 | ) 20 | const dataSectionEndRegex2 = new RegExp(';;;;') 21 | 22 | let dataSectionStarted = false 23 | let dataSectionStartIndex = -1 24 | let dataSectionEndRegex = dataSectionEndRegex1 25 | 26 | lines.forEach((line, index) => { 27 | if (dataSectionStarted) { 28 | if (dataSectionEndRegex.test(line)) { 29 | dataSections.push({ start: dataSectionStartIndex, end: index }) 30 | dataSectionStarted = false 31 | } 32 | } else { 33 | if (dataSectionStartRegex1.test(line)) { 34 | dataSectionStarted = true 35 | dataSectionStartIndex = index 36 | dataSectionEndRegex = dataSectionEndRegex1 37 | } else if (dataSectionStartRegex2.test(line)) { 38 | dataSectionStarted = true 39 | dataSectionStartIndex = index 40 | dataSectionEndRegex = dataSectionEndRegex2 41 | } 42 | } 43 | }) 44 | 45 | return dataSections 46 | } 47 | 48 | export const checkIsDataLine = ( 49 | dataSections: DataSectionsDetail[], 50 | lineIndex: number 51 | ) => { 52 | for (const dataSection of dataSections) { 53 | if (lineIndex >= dataSection.start && lineIndex <= dataSection.end) 54 | return true 55 | } 56 | 57 | return false 58 | } 59 | -------------------------------------------------------------------------------- /src/utils/getLintConfig.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import os from 'os' 3 | import { LintConfig } from '../types/LintConfig' 4 | import { readFile } from '@sasjs/utils/file' 5 | import { getProjectRoot } from './getProjectRoot' 6 | import { LineEndings } from '../types/LineEndings' 7 | 8 | export const getDefaultHeader = () => 9 | `/**{lineEnding} @file{lineEnding} @brief {lineEnding}

SAS Macros

{lineEnding}**/` 10 | 11 | /** 12 | * Default configuration that is used when a .sasjslint file is not found 13 | */ 14 | export const DefaultLintConfiguration = { 15 | lineEndings: LineEndings.OFF, 16 | noTrailingSpaces: true, 17 | noEncodedPasswords: true, 18 | hasDoxygenHeader: true, 19 | noSpacesInFileNames: true, 20 | lowerCaseFileNames: true, 21 | maxLineLength: 80, 22 | maxHeaderLineLength: 80, 23 | maxDataLineLength: 80, 24 | noTabIndentation: true, 25 | indentationMultiple: 2, 26 | hasMacroNameInMend: true, 27 | noNestedMacros: true, 28 | hasMacroParentheses: true, 29 | strictMacroDefinition: true, 30 | noGremlins: true, 31 | defaultHeader: getDefaultHeader() 32 | } 33 | 34 | /** 35 | * Fetches the config from the .sasjslint file (at project root or home directory) and creates a LintConfig object. 36 | * Returns the default configuration when a .sasjslint file is unavailable. 37 | * @returns {Promise} resolves with an object representing the current lint configuration. 38 | */ 39 | export async function getLintConfig(): Promise { 40 | const projectRoot = await getProjectRoot() 41 | const lintFileLocation = projectRoot || os.homedir() 42 | const configuration = await readFile( 43 | path.join(lintFileLocation, '.sasjslint') 44 | ).catch((_) => { 45 | return JSON.stringify(DefaultLintConfiguration) 46 | }) 47 | return new LintConfig(JSON.parse(configuration)) 48 | } 49 | -------------------------------------------------------------------------------- /src/format/formatProject.spec.ts: -------------------------------------------------------------------------------- 1 | import { formatProject } from './formatProject' 2 | import path from 'path' 3 | import { 4 | createFile, 5 | createFolder, 6 | deleteFolder, 7 | readFile 8 | } from '@sasjs/utils/file' 9 | import { DefaultLintConfiguration } from '../utils' 10 | import * as getProjectRootModule from '../utils/getProjectRoot' 11 | jest.mock('../utils/getProjectRoot') 12 | 13 | describe('formatProject', () => { 14 | it('should format files in the current project', async () => { 15 | const content = `%macro somemacro(); \n%put 'hello';\n%mend;` 16 | const expectedContent = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;` 17 | await createFolder(path.join(__dirname, 'format-project-test')) 18 | await createFile( 19 | path.join(__dirname, 'format-project-test', 'format-project-test.sas'), 20 | content 21 | ) 22 | await createFile( 23 | path.join(__dirname, 'format-project-test', '.sasjslint'), 24 | JSON.stringify(DefaultLintConfiguration) 25 | ) 26 | jest 27 | .spyOn(getProjectRootModule, 'getProjectRoot') 28 | .mockImplementation(() => 29 | Promise.resolve(path.join(__dirname, 'format-project-test')) 30 | ) 31 | 32 | await formatProject() 33 | const result = await readFile( 34 | path.join(__dirname, 'format-project-test', 'format-project-test.sas') 35 | ) 36 | 37 | expect(result).toEqual(expectedContent) 38 | 39 | await deleteFolder(path.join(__dirname, 'format-project-test')) 40 | }) 41 | 42 | it('should throw an error when a project root is not found', async () => { 43 | jest 44 | .spyOn(getProjectRootModule, 'getProjectRoot') 45 | .mockImplementationOnce(() => Promise.resolve('')) 46 | 47 | await expect(formatProject()).rejects.toThrowError( 48 | 'SASjs Project Root was not found.' 49 | ) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /src/rules/line/noEncodedPasswords.spec.ts: -------------------------------------------------------------------------------- 1 | import { Severity } from '../../types/Severity' 2 | import { noEncodedPasswords } from './noEncodedPasswords' 3 | 4 | describe('noEncodedPasswords', () => { 5 | it('should return an empty array when the line has no encoded passwords', () => { 6 | const line = "%put 'hello';" 7 | expect(noEncodedPasswords.test(line, 1)).toEqual([]) 8 | }) 9 | 10 | it('should return an array with a single diagnostic when the line has a SASENC password', () => { 11 | const line = "%put '{SASENC}'; " 12 | expect(noEncodedPasswords.test(line, 1)).toEqual([ 13 | { 14 | message: 'Line contains encoded password', 15 | lineNumber: 1, 16 | startColumnNumber: 7, 17 | endColumnNumber: 15, 18 | severity: Severity.Error 19 | } 20 | ]) 21 | }) 22 | 23 | it('should return an array with a single diagnostic when the line has an encoded password', () => { 24 | const line = "%put '{SAS001}'; " 25 | expect(noEncodedPasswords.test(line, 1)).toEqual([ 26 | { 27 | message: 'Line contains encoded password', 28 | lineNumber: 1, 29 | startColumnNumber: 7, 30 | endColumnNumber: 15, 31 | severity: Severity.Error 32 | } 33 | ]) 34 | }) 35 | 36 | it('should return an array with multiple diagnostics when the line has encoded passwords', () => { 37 | const line = "%put '{SAS001} {SAS002}'; " 38 | expect(noEncodedPasswords.test(line, 1)).toEqual([ 39 | { 40 | message: 'Line contains encoded password', 41 | lineNumber: 1, 42 | startColumnNumber: 7, 43 | endColumnNumber: 15, 44 | severity: Severity.Error 45 | }, 46 | { 47 | message: 'Line contains encoded password', 48 | lineNumber: 1, 49 | startColumnNumber: 16, 50 | endColumnNumber: 24, 51 | severity: Severity.Error 52 | } 53 | ]) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/lint/lintFolder.ts: -------------------------------------------------------------------------------- 1 | import { listSubFoldersInFolder } from '@sasjs/utils/file' 2 | import path from 'path' 3 | import { Diagnostic, LintConfig } from '../types' 4 | import { asyncForEach, getLintConfig, isIgnored, listSasFiles } from '../utils' 5 | import { lintFile } from './lintFile' 6 | 7 | const excludeFolders = [ 8 | '.git', 9 | '.github', 10 | '.vscode', 11 | 'node_modules', 12 | 'sasjsbuild', 13 | 'sasjsresults' 14 | ] 15 | 16 | /** 17 | * Analyses and produces a set of diagnostics for the folder at the given path. 18 | * @param {string} folderPath - the path to the folder to be linted. 19 | * @param {LintConfig} configuration - an optional configuration. When not passed in, this is read from the .sasjslint file. 20 | * @returns {Promise>} Resolves with a map with array of diagnostic objects, each containing a warning, line number and column number, and grouped by file path. 21 | */ 22 | export const lintFolder = async ( 23 | folderPath: string, 24 | configuration?: LintConfig 25 | ) => { 26 | const config = configuration || (await getLintConfig()) 27 | let diagnostics: Map = new Map() 28 | 29 | if (await isIgnored(folderPath, config)) return diagnostics 30 | 31 | const fileNames = await listSasFiles(folderPath) 32 | await asyncForEach(fileNames, async (fileName) => { 33 | const filePath = path.join(folderPath, fileName) 34 | diagnostics.set(filePath, await lintFile(filePath, config)) 35 | }) 36 | 37 | const subFolders = (await listSubFoldersInFolder(folderPath)).filter( 38 | (f: string) => !excludeFolders.includes(f) 39 | ) 40 | 41 | await asyncForEach(subFolders, async (subFolder) => { 42 | const subFolderPath = path.join(folderPath, subFolder) 43 | const subFolderDiagnostics = await lintFolder(subFolderPath, config) 44 | diagnostics = new Map([...diagnostics, ...subFolderDiagnostics]) 45 | }) 46 | 47 | return diagnostics 48 | } 49 | -------------------------------------------------------------------------------- /src/lint/lintText.spec.ts: -------------------------------------------------------------------------------- 1 | import { lintText } from './lintText' 2 | import { Severity } from '../types/Severity' 3 | 4 | describe('lintText', () => { 5 | it('should identify trailing spaces', async () => { 6 | const text = `/** 7 | @file 8 | **/ 9 | %put 'hello'; 10 | %put 'world'; ` 11 | const results = await lintText(text) 12 | 13 | expect(results.length).toEqual(2) 14 | expect(results[0]).toEqual({ 15 | message: 'Line contains trailing spaces', 16 | lineNumber: 4, 17 | startColumnNumber: 18, 18 | endColumnNumber: 18, 19 | severity: Severity.Warning 20 | }) 21 | expect(results[1]).toEqual({ 22 | message: 'Line contains trailing spaces', 23 | lineNumber: 5, 24 | startColumnNumber: 22, 25 | endColumnNumber: 23, 26 | severity: Severity.Warning 27 | }) 28 | }) 29 | 30 | it('should identify encoded passwords', async () => { 31 | const text = `/** 32 | @file 33 | **/ 34 | %put '{SAS001}';` 35 | const results = await lintText(text) 36 | 37 | expect(results.length).toEqual(1) 38 | expect(results[0]).toEqual({ 39 | message: 'Line contains encoded password', 40 | lineNumber: 4, 41 | startColumnNumber: 11, 42 | endColumnNumber: 19, 43 | severity: Severity.Error 44 | }) 45 | }) 46 | 47 | it('should identify missing doxygen header', async () => { 48 | const text = `%put 'hello';` 49 | const results = await lintText(text) 50 | 51 | expect(results.length).toEqual(1) 52 | expect(results[0]).toEqual({ 53 | message: 'File missing Doxygen header', 54 | lineNumber: 1, 55 | startColumnNumber: 1, 56 | endColumnNumber: 1, 57 | severity: Severity.Warning 58 | }) 59 | }) 60 | 61 | it('should return an empty list with an empty file', async () => { 62 | const text = `/** 63 | @file 64 | **/` 65 | const results = await lintText(text) 66 | 67 | expect(results.length).toEqual(0) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /src/rules/line/noGremlins.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic, LintConfig } from '../../types' 2 | import { LineLintRule } from '../../types/LintRule' 3 | import { LintRuleType } from '../../types/LintRuleType' 4 | import { Severity } from '../../types/Severity' 5 | import { gremlinCharacters } from '../../utils' 6 | 7 | const name = 'noGremlins' 8 | const description = 'Disallow characters specified in gremlins array' 9 | const message = 'Line contains a gremlin' 10 | 11 | const test = (value: string, lineNumber: number, config?: LintConfig) => { 12 | const severity = config?.severityLevel[name] || Severity.Warning 13 | const allowedGremlins = config?.allowedGremlins || [] 14 | 15 | const diagnostics: Diagnostic[] = [] 16 | 17 | const gremlins: any = {} 18 | 19 | for (const [hexCode, gremlinConfig] of Object.entries(gremlinCharacters)) { 20 | if (!allowedGremlins.includes(hexCode)) { 21 | gremlins[charFromHex(hexCode)] = Object.assign({}, gremlinConfig, { 22 | hexCode 23 | }) 24 | } 25 | } 26 | 27 | const regexpWithAllChars = new RegExp( 28 | Object.keys(gremlins) 29 | .map((char) => `${char}+`) 30 | .join('|'), 31 | 'g' 32 | ) 33 | 34 | let match 35 | while ((match = regexpWithAllChars.exec(value))) { 36 | const matchedCharacter = match[0][0] 37 | const gremlin = gremlins[matchedCharacter] 38 | 39 | diagnostics.push({ 40 | message: `${message}: ${gremlin.description}, hexCode(${gremlin.hexCode})`, 41 | lineNumber, 42 | startColumnNumber: match.index + 1, 43 | endColumnNumber: match.index + 1 + match[0].length, 44 | severity 45 | }) 46 | } 47 | 48 | return diagnostics 49 | } 50 | 51 | /** 52 | * Lint rule that checks if a given line of text contains any gremlins. 53 | */ 54 | export const noGremlins: LineLintRule = { 55 | type: LintRuleType.Line, 56 | name, 57 | description, 58 | message, 59 | test 60 | } 61 | 62 | export const charFromHex = (hexCode: string) => 63 | String.fromCodePoint(parseInt(hexCode)) 64 | -------------------------------------------------------------------------------- /src/rules/file/noNestedMacros.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic } from '../../types/Diagnostic' 2 | import { FileLintRule } from '../../types/LintRule' 3 | import { LintRuleType } from '../../types/LintRuleType' 4 | import { Severity } from '../../types/Severity' 5 | import { getColumnNumber } from '../../utils/getColumnNumber' 6 | import { parseMacros } from '../../utils/parseMacros' 7 | import { LintConfig } from '../../types' 8 | import { LineEndings } from '../../types/LineEndings' 9 | 10 | const name = 'noNestedMacros' 11 | const description = 'Enfoces the absence of nested macro definitions.' 12 | const message = `Macro definition for '{macro}' present in macro '{parent}'` 13 | 14 | const test = (value: string, config?: LintConfig) => { 15 | const lineEnding = config?.lineEndings === LineEndings.CRLF ? '\r\n' : '\n' 16 | const lines: string[] = value ? value.split(lineEnding) : [] 17 | const diagnostics: Diagnostic[] = [] 18 | const macros = parseMacros(value, config) 19 | const severity = config?.severityLevel[name] || Severity.Warning 20 | 21 | macros 22 | .filter((m) => !!m.parentMacro) 23 | .forEach((macro) => { 24 | diagnostics.push({ 25 | message: message 26 | .replace('{macro}', macro.name) 27 | .replace('{parent}', macro.parentMacro), 28 | lineNumber: macro.startLineNumbers![0] as number, 29 | startColumnNumber: getColumnNumber( 30 | lines[(macro.startLineNumbers![0] as number) - 1], 31 | '%macro' 32 | ), 33 | endColumnNumber: 34 | getColumnNumber( 35 | lines[(macro.startLineNumbers![0] as number) - 1], 36 | '%macro' 37 | ) + 38 | lines[(macro.startLineNumbers![0] as number) - 1].trim().length - 39 | 1, 40 | severity 41 | }) 42 | }) 43 | return diagnostics 44 | } 45 | 46 | /** 47 | * Lint rule that checks for the absence of nested macro definitions. 48 | */ 49 | export const noNestedMacros: FileLintRule = { 50 | type: LintRuleType.File, 51 | name, 52 | description, 53 | message, 54 | test 55 | } 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Build output 107 | build -------------------------------------------------------------------------------- /src/rules/file/hasMacroParentheses.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic } from '../../types/Diagnostic' 2 | import { FileLintRule } from '../../types/LintRule' 3 | import { LintRuleType } from '../../types/LintRuleType' 4 | import { Severity } from '../../types/Severity' 5 | import { getColumnNumber } from '../../utils/getColumnNumber' 6 | import { parseMacros } from '../../utils/parseMacros' 7 | import { LintConfig } from '../../types' 8 | 9 | const name = 'hasMacroParentheses' 10 | const description = 'Enforces the presence of parentheses in macro definitions.' 11 | const message = 'Macro definition missing parentheses' 12 | 13 | const test = (value: string, config?: LintConfig) => { 14 | const diagnostics: Diagnostic[] = [] 15 | const macros = parseMacros(value, config) 16 | const severity = config?.severityLevel[name] || Severity.Warning 17 | 18 | macros.forEach((macro) => { 19 | if (!macro.name) { 20 | diagnostics.push({ 21 | message: 'Macro definition missing name', 22 | lineNumber: macro.startLineNumbers![0], 23 | startColumnNumber: getColumnNumber( 24 | macro.declarationLines![0], 25 | '%macro' 26 | ), 27 | endColumnNumber: 28 | getColumnNumber(macro.declarationLines![0], '%macro') + 29 | macro.declaration.length, 30 | severity 31 | }) 32 | } else if (!macro.declarationLines.find((dl) => dl.includes('('))) { 33 | const macroNameLineIndex = macro.declarationLines.findIndex((dl) => 34 | dl.includes(macro.name) 35 | ) 36 | diagnostics.push({ 37 | message, 38 | lineNumber: macro.startLineNumbers![macroNameLineIndex], 39 | startColumnNumber: getColumnNumber( 40 | macro.declarationLines[macroNameLineIndex], 41 | macro.name 42 | ), 43 | endColumnNumber: 44 | getColumnNumber( 45 | macro.declarationLines[macroNameLineIndex], 46 | macro.name 47 | ) + 48 | macro.name.length - 49 | 1, 50 | severity 51 | }) 52 | } 53 | }) 54 | 55 | return diagnostics 56 | } 57 | 58 | /** 59 | * Lint rule that enforces the presence of parentheses in macro definitions.. 60 | */ 61 | export const hasMacroParentheses: FileLintRule = { 62 | type: LintRuleType.File, 63 | name, 64 | description, 65 | message, 66 | test 67 | } 68 | -------------------------------------------------------------------------------- /src/lint/lintFile.spec.ts: -------------------------------------------------------------------------------- 1 | import { lintFile } from './lintFile' 2 | import { Severity } from '../types/Severity' 3 | import path from 'path' 4 | 5 | const expectedDiagnostics = [ 6 | { 7 | message: 'Line contains trailing spaces', 8 | lineNumber: 1, 9 | startColumnNumber: 1, 10 | endColumnNumber: 2, 11 | severity: Severity.Warning 12 | }, 13 | { 14 | message: 'Line contains trailing spaces', 15 | lineNumber: 2, 16 | startColumnNumber: 1, 17 | endColumnNumber: 2, 18 | severity: Severity.Warning 19 | }, 20 | { 21 | message: 'File name contains spaces', 22 | lineNumber: 1, 23 | startColumnNumber: 1, 24 | endColumnNumber: 1, 25 | severity: Severity.Warning 26 | }, 27 | { 28 | message: 'File name contains uppercase characters', 29 | lineNumber: 1, 30 | startColumnNumber: 1, 31 | endColumnNumber: 1, 32 | severity: Severity.Warning 33 | }, 34 | { 35 | message: 'File missing Doxygen header', 36 | lineNumber: 1, 37 | startColumnNumber: 1, 38 | endColumnNumber: 1, 39 | severity: Severity.Warning 40 | }, 41 | { 42 | message: 'Line contains encoded password', 43 | lineNumber: 5, 44 | startColumnNumber: 10, 45 | endColumnNumber: 18, 46 | severity: Severity.Error 47 | }, 48 | { 49 | message: 'Line contains a tab character (09x)', 50 | lineNumber: 7, 51 | startColumnNumber: 1, 52 | endColumnNumber: 2, 53 | severity: Severity.Warning 54 | }, 55 | { 56 | message: 'Line has incorrect indentation - 3 spaces', 57 | lineNumber: 6, 58 | startColumnNumber: 1, 59 | endColumnNumber: 1, 60 | severity: Severity.Warning 61 | }, 62 | { 63 | message: '%mend statement is missing macro name - mf_getuniquelibref', 64 | lineNumber: 17, 65 | startColumnNumber: 3, 66 | endColumnNumber: 9, 67 | severity: Severity.Warning 68 | } 69 | ] 70 | 71 | describe('lintFile', () => { 72 | it('should identify lint issues in a given file', async () => { 73 | const results = await lintFile( 74 | path.join(__dirname, '..', 'Example File.sas') 75 | ) 76 | 77 | expect(results.length).toEqual(expectedDiagnostics.length) 78 | expect(results).toContainEqual(expectedDiagnostics[0]) 79 | expect(results).toContainEqual(expectedDiagnostics[1]) 80 | expect(results).toContainEqual(expectedDiagnostics[2]) 81 | expect(results).toContainEqual(expectedDiagnostics[3]) 82 | expect(results).toContainEqual(expectedDiagnostics[4]) 83 | expect(results).toContainEqual(expectedDiagnostics[5]) 84 | expect(results).toContainEqual(expectedDiagnostics[6]) 85 | expect(results).toContainEqual(expectedDiagnostics[7]) 86 | expect(results).toContainEqual(expectedDiagnostics[8]) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /src/utils/trimComments.spec.ts: -------------------------------------------------------------------------------- 1 | import { trimComments } from './trimComments' 2 | 3 | describe('trimComments', () => { 4 | it('should return statment', () => { 5 | expect( 6 | trimComments(` 7 | /* some comment */ some code; 8 | `) 9 | ).toEqual({ statement: 'some code;', commentStarted: false }) 10 | 11 | expect( 12 | trimComments(` 13 | /*/ some comment */ some code; 14 | `) 15 | ).toEqual({ statement: 'some code;', commentStarted: false }) 16 | 17 | expect( 18 | trimComments(` 19 | some code;/*/ some comment */ some code; 20 | `) 21 | ).toEqual({ statement: 'some code; some code;', commentStarted: false }) 22 | 23 | expect( 24 | trimComments(`/* some comment */ 25 | /* some comment */ CODE_Keyword1 /* some comment */ CODE_Keyword2/* some comment */;/* some comment */ 26 | /* some comment */`) 27 | ).toEqual({ 28 | statement: 'CODE_Keyword1 CODE_Keyword2;', 29 | commentStarted: false 30 | }) 31 | }) 32 | 33 | it('should return statment, having multi-line comment', () => { 34 | expect( 35 | trimComments(` 36 | /* some 37 | comment */ 38 | some code; 39 | `) 40 | ).toEqual({ statement: 'some code;', commentStarted: false }) 41 | }) 42 | 43 | it('should return statment, having multi-line comment and some code present in comment', () => { 44 | expect( 45 | trimComments(` 46 | /* some 47 | some code; 48 | comment */ 49 | some other code; 50 | `) 51 | ).toEqual({ statement: 'some other code;', commentStarted: false }) 52 | }) 53 | 54 | it('should return empty statment, having only comment', () => { 55 | expect( 56 | trimComments(` 57 | /* some 58 | some code; 59 | comment */ 60 | `) 61 | ).toEqual({ statement: '', commentStarted: false }) 62 | }) 63 | 64 | it('should return empty statment, having continuity in comment', () => { 65 | expect( 66 | trimComments(` 67 | /* some 68 | some code; 69 | `) 70 | ).toEqual({ statement: '', commentStarted: true }) 71 | }) 72 | 73 | it('should return statment, having already started comment and ends', () => { 74 | expect( 75 | trimComments( 76 | ` 77 | comment */ 78 | some code; 79 | `, 80 | true 81 | ) 82 | ).toEqual({ statement: 'some code;', commentStarted: false }) 83 | }) 84 | 85 | it('should return empty statment, having already started comment and continuity in comment', () => { 86 | expect( 87 | trimComments( 88 | ` 89 | some code; 90 | `, 91 | true 92 | ) 93 | ).toEqual({ statement: '', commentStarted: true }) 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /src/rules/file/hasDoxygenHeader.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig } from '../../types' 2 | import { LineEndings } from '../../types/LineEndings' 3 | import { FileLintRule } from '../../types/LintRule' 4 | import { LintRuleType } from '../../types/LintRuleType' 5 | import { Severity } from '../../types/Severity' 6 | import { DefaultLintConfiguration } from '../../utils/getLintConfig' 7 | 8 | const name = 'hasDoxygenHeader' 9 | const description = 10 | 'Enforce the presence of a Doxygen header at the start of each file.' 11 | const message = 'File missing Doxygen header' 12 | const messageForSingleAsterisk = 13 | 'File not following Doxygen header style, use double asterisks' 14 | 15 | const test = (value: string, config?: LintConfig) => { 16 | const lineEnding = config?.lineEndings === LineEndings.CRLF ? '\r\n' : '\n' 17 | const severity = config?.severityLevel[name] || Severity.Warning 18 | 19 | try { 20 | const hasFileHeader = value.trimStart().startsWith('/**') 21 | if (hasFileHeader) return [] 22 | 23 | const hasFileHeaderWithSingleAsterisk = value.trimStart().startsWith('/*') 24 | if (hasFileHeaderWithSingleAsterisk) 25 | return [ 26 | { 27 | message: messageForSingleAsterisk, 28 | lineNumber: 29 | (value.split('/*')![0]!.match(new RegExp(lineEnding, 'g')) ?? []) 30 | .length + 1, 31 | startColumnNumber: 1, 32 | endColumnNumber: 1, 33 | severity 34 | } 35 | ] 36 | 37 | return [ 38 | { 39 | message, 40 | lineNumber: 1, 41 | startColumnNumber: 1, 42 | endColumnNumber: 1, 43 | severity 44 | } 45 | ] 46 | } catch (e) { 47 | return [ 48 | { 49 | message, 50 | lineNumber: 1, 51 | startColumnNumber: 1, 52 | endColumnNumber: 1, 53 | severity 54 | } 55 | ] 56 | } 57 | } 58 | 59 | const fix = (value: string, config?: LintConfig): string => { 60 | const result = test(value, config) 61 | if (result.length === 0) { 62 | return value 63 | } else if (result[0].message == messageForSingleAsterisk) 64 | return value.replace('/*', '/**') 65 | 66 | config = config || new LintConfig(DefaultLintConfiguration) 67 | const lineEndingConfig = config?.lineEndings || LineEndings.LF 68 | const lineEnding = lineEndingConfig === LineEndings.LF ? '\n' : '\r\n' 69 | 70 | return `${config?.defaultHeader.replace( 71 | /{lineEnding}/g, 72 | lineEnding 73 | )}${lineEnding}${value}` 74 | } 75 | 76 | /** 77 | * Lint rule that checks for the presence of a Doxygen header in a given file. 78 | */ 79 | export const hasDoxygenHeader: FileLintRule = { 80 | type: LintRuleType.File, 81 | name, 82 | description, 83 | message, 84 | test, 85 | fix 86 | } 87 | -------------------------------------------------------------------------------- /src/format/formatFolder.ts: -------------------------------------------------------------------------------- 1 | import { listSubFoldersInFolder } from '@sasjs/utils/file' 2 | import path from 'path' 3 | import { lintFolder } from '../lint' 4 | import { FormatResult } from '../types' 5 | import { LintConfig } from '../types/LintConfig' 6 | import { asyncForEach } from '../utils/asyncForEach' 7 | import { getLintConfig } from '../utils/getLintConfig' 8 | import { listSasFiles } from '../utils/listSasFiles' 9 | import { formatFile } from './formatFile' 10 | 11 | const excludeFolders = [ 12 | '.git', 13 | '.github', 14 | '.vscode', 15 | 'node_modules', 16 | 'sasjsbuild', 17 | 'sasjsresults' 18 | ] 19 | 20 | /** 21 | * Automatically formats all SAS files in the folder at the given path. 22 | * @param {string} folderPath - the path to the folder to be formatted. 23 | * @param {LintConfig} configuration - an optional configuration. When not passed in, this is read from the .sasjslint file. 24 | * @returns {Promise} Resolves successfully when all SAS files in the given folder have been formatted. 25 | */ 26 | export const formatFolder = async ( 27 | folderPath: string, 28 | configuration?: LintConfig 29 | ): Promise => { 30 | const config = configuration || (await getLintConfig()) 31 | const diagnosticsBeforeFormat = await lintFolder(folderPath) 32 | const diagnosticsCountBeforeFormat = Array.from( 33 | diagnosticsBeforeFormat.values() 34 | ).reduce((a, b) => a + b.length, 0) 35 | 36 | const fileNames = await listSasFiles(folderPath) 37 | await asyncForEach(fileNames, async (fileName) => { 38 | const filePath = path.join(folderPath, fileName) 39 | await formatFile(filePath) 40 | }) 41 | 42 | const subFolders = (await listSubFoldersInFolder(folderPath)).filter( 43 | (f: string) => !excludeFolders.includes(f) 44 | ) 45 | 46 | await asyncForEach(subFolders, async (subFolder) => { 47 | await formatFolder(path.join(folderPath, subFolder), config) 48 | }) 49 | 50 | const diagnosticsAfterFormat = await lintFolder(folderPath) 51 | const diagnosticsCountAfterFormat = Array.from( 52 | diagnosticsAfterFormat.values() 53 | ).reduce((a, b) => a + b.length, 0) 54 | 55 | const fixedDiagnosticsCount = 56 | diagnosticsCountBeforeFormat - diagnosticsCountAfterFormat 57 | 58 | const updatedFilePaths: string[] = [] 59 | 60 | Array.from(diagnosticsBeforeFormat.keys()).forEach((filePath) => { 61 | const diagnosticsBefore = diagnosticsBeforeFormat.get(filePath) || [] 62 | const diagnosticsAfter = diagnosticsAfterFormat.get(filePath) || [] 63 | 64 | if (diagnosticsBefore.length !== diagnosticsAfter.length) { 65 | updatedFilePaths.push(filePath) 66 | } 67 | }) 68 | 69 | return { 70 | updatedFilePaths, 71 | fixedDiagnosticsCount, 72 | unfixedDiagnostics: diagnosticsAfterFormat 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions to `@sasjs/lint` are very welcome! 4 | Please fill in the pull request template and make sure that your code changes are adequately covered with tests when making a PR. 5 | 6 | ## Architecture 7 | 8 | This project implements a number of rules for SAS projects and code. There are three types of rules: 9 | 10 | * File rules - rules applied at the file level 11 | * Line rules - rules applied to each line of a file 12 | * Path rules - rules applied to paths and file names 13 | 14 | When implementing a new rule, place it in the appropriate folder for its type. 15 | Please also make sure to export it from the `index.ts` file in that folder. 16 | 17 | The file for each rule typically exports an object that conforms to the `LintRule` interface. 18 | This means it will have a `type`, `name`, `description` and `message` at a minimum. 19 | 20 | File, line and path lint rules also have a `test` property. 21 | This is a function that will run a piece of logic against the supplied item and produce an array of `Diagnostic` objects. 22 | These objects can be used in the consuming application to display the problems in the code. 23 | 24 | With some lint rules, we can also write logic that can automatically fix the issues found. 25 | These rules will also have a `fix` property, which is a function that takes the original content - 26 | either a line or the entire contents of a file, and returns the transformed content with the fix applied. 27 | 28 | ## Testing 29 | 30 | Testing is one of the most important steps when developing a new lint rule. 31 | It helps us ensure that our lint rules do what they are intended to do. 32 | 33 | We use `jest` for testing, and since most of the code is based on pure functions, there is little mocking to do. 34 | This makes `@sasjs/lint` very easy to unit test, and so there is no excuse for not testing a new rule. :) 35 | 36 | When adding a new rule, please make sure that all positive and negative scenarios are tested in separate test cases. 37 | When modifying an existing rule, ensure that your changes haven't affected existing functionality by running the tests on your machine. 38 | 39 | You can run the tests using `npm test`. 40 | 41 | ## Code Style 42 | 43 | This repository uses `Prettier` to ensure a uniform code style. 44 | If you are using VS Code for development, you can automatically fix your code to match the style as follows: 45 | 46 | - Install the `Prettier` extension for VS Code. 47 | - Open your `settings.json` file by choosing 'Preferences: Open Settings (JSON)' from the command palette. 48 | - Add the following items to the JSON. 49 | ``` 50 | "editor.formatOnSave": true, 51 | "editor.formatOnPaste": true, 52 | ``` 53 | 54 | If you are using another editor, or are unable to install the extension, you can run `npm run lint:fix` to fix the formatting after you've made your changes. 55 | -------------------------------------------------------------------------------- /src/rules/line/indentationMultiple.spec.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig, Severity } from '../../types' 2 | import { indentationMultiple } from './indentationMultiple' 3 | 4 | describe('indentationMultiple', () => { 5 | it('should return an empty array when the line is indented by two spaces', () => { 6 | const line = " %put 'hello';" 7 | const config = new LintConfig({ indentationMultiple: 2 }) 8 | expect(indentationMultiple.test(line, 1, config)).toEqual([]) 9 | }) 10 | 11 | it('should return an empty array when the line is indented by a multiple of 2 spaces', () => { 12 | const line = " %put 'hello';" 13 | const config = new LintConfig({ indentationMultiple: 2 }) 14 | expect(indentationMultiple.test(line, 1, config)).toEqual([]) 15 | }) 16 | 17 | it('should ignore indentation when the multiple is set to 0', () => { 18 | const line = " %put 'hello';" 19 | const config = new LintConfig({ indentationMultiple: 0 }) 20 | expect(indentationMultiple.test(line, 1, config)).toEqual([]) 21 | }) 22 | 23 | it('should return an empty array when the line is not indented', () => { 24 | const line = "%put 'hello';" 25 | const config = new LintConfig({ indentationMultiple: 2 }) 26 | expect(indentationMultiple.test(line, 1, config)).toEqual([]) 27 | }) 28 | 29 | it('should return an array with a single diagnostic when the line is indented incorrectly', () => { 30 | const line = " %put 'hello';" 31 | const config = new LintConfig({ indentationMultiple: 2 }) 32 | expect(indentationMultiple.test(line, 1, config)).toEqual([ 33 | { 34 | message: `Line has incorrect indentation - 3 spaces`, 35 | lineNumber: 1, 36 | startColumnNumber: 1, 37 | endColumnNumber: 1, 38 | severity: Severity.Warning 39 | } 40 | ]) 41 | }) 42 | 43 | it('should return an array with a single diagnostic when the line is indented incorrectly', () => { 44 | const line = " %put 'hello';" 45 | const config = new LintConfig({ indentationMultiple: 3 }) 46 | expect(indentationMultiple.test(line, 1, config)).toEqual([ 47 | { 48 | message: `Line has incorrect indentation - 2 spaces`, 49 | lineNumber: 1, 50 | startColumnNumber: 1, 51 | endColumnNumber: 1, 52 | severity: Severity.Warning 53 | } 54 | ]) 55 | }) 56 | 57 | it('should fall back to a default of 2 spaces', () => { 58 | const line = " %put 'hello';" 59 | expect(indentationMultiple.test(line, 1)).toEqual([ 60 | { 61 | message: `Line has incorrect indentation - 1 space`, 62 | lineNumber: 1, 63 | startColumnNumber: 1, 64 | endColumnNumber: 1, 65 | severity: Severity.Warning 66 | } 67 | ]) 68 | }) 69 | 70 | it('should return an empty array for lines within the default indentation', () => { 71 | const line = " %put 'hello';" 72 | expect(indentationMultiple.test(line, 1)).toEqual([]) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /src/rules/file/noNestedMacros.spec.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig } from '../../types' 2 | import { Severity } from '../../types/Severity' 3 | import { noNestedMacros } from './noNestedMacros' 4 | 5 | describe('noNestedMacros', () => { 6 | it('should return an empty array when no nested macro', () => { 7 | const content = ` 8 | %macro somemacro(); 9 | %put &sysmacroname; 10 | %mend somemacro;` 11 | 12 | expect(noNestedMacros.test(content)).toEqual([]) 13 | }) 14 | 15 | it('should return an array with a single diagnostic when a macro contains a nested macro definition', () => { 16 | const content = ` 17 | %macro outer(); 18 | /* any amount of arbitrary code */ 19 | %macro inner(); 20 | %put inner; 21 | %mend; 22 | %inner() 23 | %put outer; 24 | %mend; 25 | 26 | %outer()` 27 | 28 | expect(noNestedMacros.test(content)).toEqual([ 29 | { 30 | message: "Macro definition for 'inner' present in macro 'outer'", 31 | lineNumber: 4, 32 | startColumnNumber: 7, 33 | endColumnNumber: 21, 34 | severity: Severity.Warning 35 | } 36 | ]) 37 | }) 38 | 39 | it('should return an array with two diagnostics when nested macros are defined at 2 levels', () => { 40 | const content = ` 41 | %macro outer(); 42 | /* any amount of arbitrary code */ 43 | %macro inner(); 44 | %put inner; 45 | 46 | %macro inner2(); 47 | %put inner2; 48 | %mend; 49 | %mend; 50 | %inner() 51 | %put outer; 52 | %mend; 53 | 54 | %outer()` 55 | 56 | expect(noNestedMacros.test(content)).toContainEqual({ 57 | message: "Macro definition for 'inner' present in macro 'outer'", 58 | lineNumber: 4, 59 | startColumnNumber: 7, 60 | endColumnNumber: 21, 61 | severity: Severity.Warning 62 | }) 63 | expect(noNestedMacros.test(content)).toContainEqual({ 64 | message: "Macro definition for 'inner2' present in macro 'inner'", 65 | lineNumber: 7, 66 | startColumnNumber: 17, 67 | endColumnNumber: 32, 68 | severity: Severity.Warning 69 | }) 70 | }) 71 | 72 | it('should return an empty array when the file is undefined', () => { 73 | const content = undefined 74 | 75 | expect(noNestedMacros.test(content as unknown as string)).toEqual([]) 76 | }) 77 | 78 | it('should use the configured line ending while testing content', () => { 79 | const content = `%macro outer();\r\n%macro inner;\r\n%mend inner;\r\n%mend outer;` 80 | 81 | const diagnostics = noNestedMacros.test( 82 | content, 83 | new LintConfig({ lineEndings: 'crlf' }) 84 | ) 85 | 86 | expect(diagnostics).toEqual([ 87 | { 88 | message: "Macro definition for 'inner' present in macro 'outer'", 89 | lineNumber: 2, 90 | startColumnNumber: 1, 91 | endColumnNumber: 13, 92 | severity: Severity.Warning 93 | } 94 | ]) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /src/rules/line/maxLineLength.spec.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig, Severity } from '../../types' 2 | import { maxLineLength } from './maxLineLength' 3 | 4 | describe('maxLineLength', () => { 5 | it('should return an empty array when the line is within the specified length', () => { 6 | const line = "%put 'hello';" 7 | const config = new LintConfig({ maxLineLength: 60 }) 8 | expect(maxLineLength.test(line, 1, config)).toEqual([]) 9 | }) 10 | 11 | it('should return an array with a single diagnostic when the line exceeds the specified length', () => { 12 | const line = "%put 'hello';" 13 | const config = new LintConfig({ maxLineLength: 10 }) 14 | expect(maxLineLength.test(line, 1, config)).toEqual([ 15 | { 16 | message: `Line exceeds maximum length by 3 characters`, 17 | lineNumber: 1, 18 | startColumnNumber: 1, 19 | endColumnNumber: 1, 20 | severity: Severity.Warning 21 | } 22 | ]) 23 | }) 24 | 25 | it('should fall back to a default of 80 characters', () => { 26 | const line = 27 | 'Prow scuttle parrel provost Sail ho shrouds spirits boom mizzenmast yardarm. Pinnace holystone.' 28 | expect(maxLineLength.test(line, 1)).toEqual([ 29 | { 30 | message: `Line exceeds maximum length by 15 characters`, 31 | lineNumber: 1, 32 | startColumnNumber: 1, 33 | endColumnNumber: 1, 34 | severity: Severity.Warning 35 | } 36 | ]) 37 | }) 38 | 39 | it('should return an empty array for lines within the default length', () => { 40 | const line = 41 | 'Prow scuttle parrel provost Sail ho shrouds spirits boom mizzenmast yard' 42 | expect(maxLineLength.test(line, 1)).toEqual([]) 43 | }) 44 | 45 | it('should return an array with a single diagnostic when the line in header section exceeds the specified length', () => { 46 | const line = 'This line is from header section' 47 | const config = new LintConfig({ 48 | maxLineLength: 10, 49 | maxHeaderLineLength: 15 50 | }) 51 | expect(maxLineLength.test(line, 1, config, { isHeaderLine: true })).toEqual( 52 | [ 53 | { 54 | message: `Line exceeds maximum length by ${ 55 | line.length - config.maxHeaderLineLength 56 | } characters`, 57 | lineNumber: 1, 58 | startColumnNumber: 1, 59 | endColumnNumber: 1, 60 | severity: Severity.Warning 61 | } 62 | ] 63 | ) 64 | }) 65 | 66 | it('should return an array with a single diagnostic when the line in data section exceeds the specified length', () => { 67 | const line = 'GROUP_LOGIC:$3. SUBGROUP_LOGIC:$3. SUBGROUP_ID:8.' 68 | const config = new LintConfig({ 69 | maxLineLength: 10, 70 | maxDataLineLength: 15 71 | }) 72 | expect(maxLineLength.test(line, 1, config, { isDataLine: true })).toEqual([ 73 | { 74 | message: `Line exceeds maximum length by ${ 75 | line.length - config.maxDataLineLength 76 | } characters`, 77 | lineNumber: 1, 78 | startColumnNumber: 1, 79 | endColumnNumber: 1, 80 | severity: Severity.Warning 81 | } 82 | ]) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /src/rules/file/lineEndings.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic, LintConfig } from '../../types' 2 | import { LineEndings } from '../../types/LineEndings' 3 | import { FileLintRule } from '../../types/LintRule' 4 | import { LintRuleType } from '../../types/LintRuleType' 5 | import { Severity } from '../../types/Severity' 6 | 7 | const name = 'lineEndings' 8 | const description = 'Ensures line endings conform to the configured type.' 9 | const message = 'Incorrect line ending - {actual} instead of {expected}' 10 | 11 | const test = (value: string, config?: LintConfig) => { 12 | const lineEndingConfig = config?.lineEndings || LineEndings.LF 13 | const expectedLineEnding = 14 | lineEndingConfig === LineEndings.LF ? '{lf}' : '{crlf}' 15 | const incorrectLineEnding = expectedLineEnding === '{lf}' ? '{crlf}' : '{lf}' 16 | 17 | const lines = value 18 | .replace(/\r\n/g, '{crlf}') 19 | .replace(/\n/g, '{lf}') 20 | .split(new RegExp(`(?<=${expectedLineEnding})`)) 21 | const diagnostics: Diagnostic[] = [] 22 | const severity = config?.severityLevel[name] || Severity.Warning 23 | 24 | let indexOffset = 0 25 | 26 | lines.forEach((line, index) => { 27 | if (line.endsWith(incorrectLineEnding)) { 28 | diagnostics.push({ 29 | message: message 30 | .replace('{expected}', expectedLineEnding === '{lf}' ? 'LF' : 'CRLF') 31 | .replace('{actual}', incorrectLineEnding === '{lf}' ? 'LF' : 'CRLF'), 32 | lineNumber: index + 1 + indexOffset, 33 | startColumnNumber: line.indexOf(incorrectLineEnding), 34 | endColumnNumber: line.indexOf(incorrectLineEnding) + 1, 35 | severity 36 | }) 37 | } else { 38 | const splitLine = line.split(new RegExp(`(?<=${incorrectLineEnding})`)) 39 | if (splitLine.length > 1) { 40 | indexOffset += splitLine.length - 1 41 | } 42 | splitLine.forEach((l, i) => { 43 | if (l.endsWith(incorrectLineEnding)) { 44 | diagnostics.push({ 45 | message: message 46 | .replace( 47 | '{expected}', 48 | expectedLineEnding === '{lf}' ? 'LF' : 'CRLF' 49 | ) 50 | .replace( 51 | '{actual}', 52 | incorrectLineEnding === '{lf}' ? 'LF' : 'CRLF' 53 | ), 54 | lineNumber: index + i + 1, 55 | startColumnNumber: l.indexOf(incorrectLineEnding), 56 | endColumnNumber: l.indexOf(incorrectLineEnding) + 1, 57 | severity 58 | }) 59 | } 60 | }) 61 | } 62 | }) 63 | return diagnostics 64 | } 65 | 66 | const fix = (value: string, config?: LintConfig): string => { 67 | const lineEndingConfig = config?.lineEndings || LineEndings.LF 68 | 69 | return value 70 | .replace(/\r\n/g, '{crlf}') 71 | .replace(/\n/g, '{lf}') 72 | .replace(/{crlf}/g, lineEndingConfig === LineEndings.LF ? '\n' : '\r\n') 73 | .replace(/{lf}/g, lineEndingConfig === LineEndings.LF ? '\n' : '\r\n') 74 | } 75 | 76 | /** 77 | * Lint rule that checks if line endings in a file match the configured type. 78 | */ 79 | export const lineEndings: FileLintRule = { 80 | type: LintRuleType.File, 81 | name, 82 | description, 83 | message, 84 | test, 85 | fix 86 | } 87 | -------------------------------------------------------------------------------- /src/format/formatFile.spec.ts: -------------------------------------------------------------------------------- 1 | import { formatFile } from './formatFile' 2 | import path from 'path' 3 | import { createFile, deleteFile, readFile } from '@sasjs/utils/file' 4 | import { LintConfig } from '../types' 5 | 6 | describe('formatFile', () => { 7 | it('should fix linting issues in a given file', async () => { 8 | const content = `%macro somemacro(); \n%put 'hello';\n%mend;` 9 | const expectedContent = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;` 10 | await createFile(path.join(__dirname, 'format-file-test.sas'), content) 11 | const expectedResult = { 12 | updatedFilePaths: [path.join(__dirname, 'format-file-test.sas')], 13 | fixedDiagnosticsCount: 3, 14 | unfixedDiagnostics: [] 15 | } 16 | 17 | const result = await formatFile( 18 | path.join(__dirname, 'format-file-test.sas') 19 | ) 20 | const formattedContent = await readFile( 21 | path.join(__dirname, 'format-file-test.sas') 22 | ) 23 | 24 | expect(result).toEqual(expectedResult) 25 | expect(formattedContent).toEqual(expectedContent) 26 | 27 | await deleteFile(path.join(__dirname, 'format-file-test.sas')) 28 | }) 29 | 30 | it('should use the provided config if available', async () => { 31 | const content = `%macro somemacro(); \n%put 'hello';\n%mend;` 32 | 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;` 33 | const expectedResult = { 34 | updatedFilePaths: [path.join(__dirname, 'format-file-config.sas')], 35 | fixedDiagnosticsCount: 4, 36 | unfixedDiagnostics: [] 37 | } 38 | await createFile(path.join(__dirname, 'format-file-config.sas'), content) 39 | 40 | const result = await formatFile( 41 | path.join(__dirname, 'format-file-config.sas'), 42 | new LintConfig({ 43 | lineEndings: 'crlf', 44 | hasMacroNameInMend: false, 45 | hasDoxygenHeader: true, 46 | noTrailingSpaces: true 47 | }) 48 | ) 49 | const formattedContent = await readFile( 50 | path.join(__dirname, 'format-file-config.sas') 51 | ) 52 | 53 | expect(result).toEqual(expectedResult) 54 | expect(formattedContent).toEqual(expectedContent) 55 | 56 | await deleteFile(path.join(__dirname, 'format-file-config.sas')) 57 | }) 58 | 59 | it('should not update any files if there are no formatting violations', async () => { 60 | const content = `/**\r\n @file\r\n @brief \r\n

SAS Macros

\r\n**/\r\n%macro somemacro();\r\n%put 'hello';\r\n%mend somemacro;` 61 | const expectedResult = { 62 | updatedFilePaths: [], 63 | fixedDiagnosticsCount: 0, 64 | unfixedDiagnostics: [] 65 | } 66 | await createFile( 67 | path.join(__dirname, 'format-file-no-violations.sas'), 68 | content 69 | ) 70 | 71 | const result = await formatFile( 72 | path.join(__dirname, 'format-file-no-violations.sas'), 73 | new LintConfig({ 74 | lineEndings: 'crlf', 75 | hasMacroNameInMend: true, 76 | hasDoxygenHeader: true, 77 | noTrailingSpaces: true 78 | }) 79 | ) 80 | const formattedContent = await readFile( 81 | path.join(__dirname, 'format-file-no-violations.sas') 82 | ) 83 | 84 | expect(result).toEqual(expectedResult) 85 | expect(formattedContent).toEqual(content) 86 | 87 | await deleteFile(path.join(__dirname, 'format-file-no-violations.sas')) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "Carus11", 10 | "name": "Carus Kyle", 11 | "avatar_url": "https://avatars.githubusercontent.com/u/4925828?v=4", 12 | "profile": "https://github.com/Carus11", 13 | "contributions": [ 14 | "ideas" 15 | ] 16 | }, 17 | { 18 | "login": "allanbowe", 19 | "name": "Allan Bowe", 20 | "avatar_url": "https://avatars.githubusercontent.com/u/4420615?v=4", 21 | "profile": "https://github.com/allanbowe", 22 | "contributions": [ 23 | "code", 24 | "test", 25 | "review", 26 | "video", 27 | "doc" 28 | ] 29 | }, 30 | { 31 | "login": "YuryShkoda", 32 | "name": "Yury Shkoda", 33 | "avatar_url": "https://avatars.githubusercontent.com/u/25773492?v=4", 34 | "profile": "https://www.erudicat.com/", 35 | "contributions": [ 36 | "code", 37 | "test", 38 | "projectManagement", 39 | "video", 40 | "doc" 41 | ] 42 | }, 43 | { 44 | "login": "krishna-acondy", 45 | "name": "Krishna Acondy", 46 | "avatar_url": "https://avatars.githubusercontent.com/u/2980428?v=4", 47 | "profile": "https://krishna-acondy.io/", 48 | "contributions": [ 49 | "code", 50 | "test", 51 | "review", 52 | "infra", 53 | "platform", 54 | "maintenance", 55 | "content" 56 | ] 57 | }, 58 | { 59 | "login": "saadjutt01", 60 | "name": "Muhammad Saad ", 61 | "avatar_url": "https://avatars.githubusercontent.com/u/8914650?v=4", 62 | "profile": "https://github.com/saadjutt01", 63 | "contributions": [ 64 | "code", 65 | "test", 66 | "review", 67 | "mentoring", 68 | "doc" 69 | ] 70 | }, 71 | { 72 | "login": "sabhas", 73 | "name": "Sabir Hassan", 74 | "avatar_url": "https://avatars.githubusercontent.com/u/82647447?v=4", 75 | "profile": "https://github.com/sabhas", 76 | "contributions": [ 77 | "code", 78 | "test", 79 | "review", 80 | "ideas" 81 | ] 82 | }, 83 | { 84 | "login": "medjedovicm", 85 | "name": "Mihajlo Medjedovic", 86 | "avatar_url": "https://avatars.githubusercontent.com/u/18329105?v=4", 87 | "profile": "https://github.com/medjedovicm", 88 | "contributions": [ 89 | "code", 90 | "test", 91 | "review", 92 | "infra" 93 | ] 94 | }, 95 | { 96 | "login": "VladislavParhomchik", 97 | "name": "Vladislav Parhomchik", 98 | "avatar_url": "https://avatars.githubusercontent.com/u/83717836?v=4", 99 | "profile": "https://github.com/VladislavParhomchik", 100 | "contributions": [ 101 | "test", 102 | "review" 103 | ] 104 | }, 105 | { 106 | "login": "McGwire-Jones", 107 | "name": "McGwire-Jones", 108 | "avatar_url": "https://avatars.githubusercontent.com/u/51411005?v=4", 109 | "profile": "https://github.com/McGwire-Jones", 110 | "contributions": [ 111 | "code" 112 | ] 113 | } 114 | ], 115 | "contributorsPerLine": 7, 116 | "projectName": "lint", 117 | "projectOwner": "sasjs", 118 | "repoType": "github", 119 | "repoHost": "https://github.com", 120 | "skipCi": true, 121 | "commitConvention": "none", 122 | "commitType": "docs" 123 | } 124 | -------------------------------------------------------------------------------- /src/utils/isIgnored.spec.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import * as fileModule from '@sasjs/utils/file' 3 | import * as getLintConfigModule from './getLintConfig' 4 | import { getProjectRoot, DefaultLintConfiguration, isIgnored } from '.' 5 | import { LintConfig } from '../types' 6 | 7 | describe('isIgnored', () => { 8 | it('should return true if provided path matches the patterns from .gitignore', async () => { 9 | jest 10 | .spyOn(getLintConfigModule, 'getLintConfig') 11 | .mockImplementationOnce( 12 | async () => new LintConfig(DefaultLintConfiguration) 13 | ) 14 | jest 15 | .spyOn(fileModule, 'fileExists') 16 | .mockImplementationOnce(async () => true) 17 | 18 | jest 19 | .spyOn(fileModule, 'readFile') 20 | .mockImplementationOnce(async () => 'sasjs') 21 | 22 | const projectRoot = await getProjectRoot() 23 | const pathToTest = path.join(projectRoot, 'sasjs') 24 | 25 | const ignored = await isIgnored(pathToTest) 26 | 27 | expect(ignored).toBeTruthy() 28 | }) 29 | 30 | it('should return true if top level path of provided path is in .gitignore', async () => { 31 | jest 32 | .spyOn(getLintConfigModule, 'getLintConfig') 33 | .mockImplementationOnce( 34 | async () => new LintConfig(DefaultLintConfiguration) 35 | ) 36 | jest 37 | .spyOn(fileModule, 'fileExists') 38 | .mockImplementationOnce(async () => true) 39 | 40 | jest 41 | .spyOn(fileModule, 'readFile') 42 | .mockImplementationOnce(async () => 'sasjs/common') 43 | 44 | const projectRoot = await getProjectRoot() 45 | const pathToTest = path.join(projectRoot, 'sasjs/common/init/init.sas') 46 | 47 | const ignored = await isIgnored(pathToTest) 48 | 49 | expect(ignored).toBeTruthy() 50 | }) 51 | 52 | it('should return true if provided path matches any pattern from ignoreList (.sasjslint)', async () => { 53 | jest 54 | .spyOn(fileModule, 'fileExists') 55 | .mockImplementationOnce(async () => false) 56 | 57 | const projectRoot = await getProjectRoot() 58 | const pathToTest = path.join(projectRoot, 'sasjs') 59 | 60 | const ignored = await isIgnored( 61 | pathToTest, 62 | new LintConfig({ 63 | ...DefaultLintConfiguration, 64 | ignoreList: ['sasjs'] 65 | }) 66 | ) 67 | 68 | expect(ignored).toBeTruthy() 69 | }) 70 | 71 | it('should return true if top level path of provided path is in ignoreList (.sasjslint)', async () => { 72 | jest 73 | .spyOn(fileModule, 'fileExists') 74 | .mockImplementationOnce(async () => false) 75 | 76 | const projectRoot = await getProjectRoot() 77 | const pathToTest = path.join(projectRoot, 'sasjs/common/init/init.sas') 78 | 79 | const ignored = await isIgnored( 80 | pathToTest, 81 | new LintConfig({ 82 | ...DefaultLintConfiguration, 83 | ignoreList: ['sasjs'] 84 | }) 85 | ) 86 | 87 | expect(ignored).toBeTruthy() 88 | }) 89 | 90 | it('should return false if provided path does not matches any pattern from .gitignore and ignoreList (.sasjslint)', async () => { 91 | jest 92 | .spyOn(fileModule, 'fileExists') 93 | .mockImplementationOnce(async () => true) 94 | 95 | jest.spyOn(fileModule, 'readFile').mockImplementationOnce(async () => '') 96 | 97 | const projectRoot = await getProjectRoot() 98 | const pathToTest = path.join(projectRoot, 'sasjs') 99 | 100 | const ignored = await isIgnored( 101 | pathToTest, 102 | new LintConfig(DefaultLintConfiguration) 103 | ) 104 | 105 | expect(ignored).toBeFalsy() 106 | }) 107 | 108 | it('should return false if provided path is equal to projectRoot', async () => { 109 | const projectRoot = await getProjectRoot() 110 | const pathToTest = path.join(projectRoot, '') 111 | 112 | const ignored = await isIgnored( 113 | pathToTest, 114 | new LintConfig(DefaultLintConfiguration) 115 | ) 116 | 117 | expect(ignored).toBeFalsy() 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /src/lint/lintProject.spec.ts: -------------------------------------------------------------------------------- 1 | import { lintProject } from './lintProject' 2 | import { Severity } from '../types/Severity' 3 | import * as getProjectRootModule from '../utils/getProjectRoot' 4 | import path from 'path' 5 | import { createFolder, createFile, readFile, deleteFolder } from '@sasjs/utils' 6 | import { DefaultLintConfiguration } from '../utils' 7 | jest.mock('../utils/getProjectRoot') 8 | 9 | const expectedFilesCount = 1 10 | const expectedDiagnostics = [ 11 | { 12 | message: 'Line contains trailing spaces', 13 | lineNumber: 1, 14 | startColumnNumber: 1, 15 | endColumnNumber: 2, 16 | severity: Severity.Warning 17 | }, 18 | { 19 | message: 'Line contains trailing spaces', 20 | lineNumber: 2, 21 | startColumnNumber: 1, 22 | endColumnNumber: 2, 23 | severity: Severity.Warning 24 | }, 25 | { 26 | message: 'File name contains spaces', 27 | lineNumber: 1, 28 | startColumnNumber: 1, 29 | endColumnNumber: 1, 30 | severity: Severity.Warning 31 | }, 32 | { 33 | message: 'File name contains uppercase characters', 34 | lineNumber: 1, 35 | startColumnNumber: 1, 36 | endColumnNumber: 1, 37 | severity: Severity.Warning 38 | }, 39 | { 40 | message: 'File missing Doxygen header', 41 | lineNumber: 1, 42 | startColumnNumber: 1, 43 | endColumnNumber: 1, 44 | severity: Severity.Warning 45 | }, 46 | { 47 | message: 'Line contains encoded password', 48 | lineNumber: 5, 49 | startColumnNumber: 10, 50 | endColumnNumber: 18, 51 | severity: Severity.Error 52 | }, 53 | { 54 | message: 'Line contains a tab character (09x)', 55 | lineNumber: 7, 56 | startColumnNumber: 1, 57 | endColumnNumber: 2, 58 | severity: Severity.Warning 59 | }, 60 | { 61 | message: 'Line has incorrect indentation - 3 spaces', 62 | lineNumber: 6, 63 | startColumnNumber: 1, 64 | endColumnNumber: 1, 65 | severity: Severity.Warning 66 | }, 67 | { 68 | message: '%mend statement is missing macro name - mf_getuniquelibref', 69 | lineNumber: 17, 70 | startColumnNumber: 3, 71 | endColumnNumber: 9, 72 | severity: Severity.Warning 73 | } 74 | ] 75 | 76 | describe('lintProject', () => { 77 | it('should identify lint issues in a given project', async () => { 78 | await createFolder(path.join(__dirname, 'lint-project-test')) 79 | const content = await readFile( 80 | path.join(__dirname, '..', 'Example File.sas') 81 | ) 82 | await createFile( 83 | path.join(__dirname, 'lint-project-test', 'Example File.sas'), 84 | content 85 | ) 86 | await createFile( 87 | path.join(__dirname, 'lint-project-test', '.sasjslint'), 88 | JSON.stringify(DefaultLintConfiguration) 89 | ) 90 | 91 | jest 92 | .spyOn(getProjectRootModule, 'getProjectRoot') 93 | .mockImplementation(() => 94 | Promise.resolve(path.join(__dirname, 'lint-project-test')) 95 | ) 96 | const results = await lintProject() 97 | 98 | expect(results.size).toEqual(expectedFilesCount) 99 | const diagnostics = results.get( 100 | path.join(__dirname, 'lint-project-test', 'Example File.sas') 101 | )! 102 | expect(diagnostics.length).toEqual(expectedDiagnostics.length) 103 | expect(diagnostics).toContainEqual(expectedDiagnostics[0]) 104 | expect(diagnostics).toContainEqual(expectedDiagnostics[1]) 105 | expect(diagnostics).toContainEqual(expectedDiagnostics[2]) 106 | expect(diagnostics).toContainEqual(expectedDiagnostics[3]) 107 | expect(diagnostics).toContainEqual(expectedDiagnostics[4]) 108 | expect(diagnostics).toContainEqual(expectedDiagnostics[5]) 109 | expect(diagnostics).toContainEqual(expectedDiagnostics[6]) 110 | expect(diagnostics).toContainEqual(expectedDiagnostics[7]) 111 | expect(diagnostics).toContainEqual(expectedDiagnostics[8]) 112 | 113 | await deleteFolder(path.join(__dirname, 'lint-project-test')) 114 | }) 115 | 116 | it('should throw an error when a project root is not found', async () => { 117 | jest 118 | .spyOn(getProjectRootModule, 'getProjectRoot') 119 | .mockImplementationOnce(() => Promise.resolve('')) 120 | 121 | await expect(lintProject()).rejects.toThrowError( 122 | 'SASjs Project Root was not found.' 123 | ) 124 | }) 125 | }) 126 | -------------------------------------------------------------------------------- /src/utils/getDataSectionDetail.spec.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig } from '../types' 2 | import { getDataSectionsDetail, checkIsDataLine } from './getDataSectionsDetail' 3 | import { DefaultLintConfiguration } from './getLintConfig' 4 | 5 | const datalines = `GROUP_LOGIC:$3. SUBGROUP_LOGIC:$3. SUBGROUP_ID:8. VARIABLE_NM:$32. OPERATOR_NM:$10. RAW_VALUE:$4000. 6 | AND,AND,1,LIBREF,CONTAINS,"'DC'" 7 | AND,OR,2,DSN,=,"'MPE_LOCK_ANYTABLE'"` 8 | 9 | const datalinesBeginPattern1 = `datalines;` 10 | const datalinesBeginPattern2 = `datalines4;` 11 | const datalinesBeginPattern3 = `cards;` 12 | const datalinesBeginPattern4 = `cards4;` 13 | const datalinesBeginPattern5 = `parmcards;` 14 | const datalinesBeginPattern6 = `parmcards4;` 15 | 16 | const datalinesEndPattern1 = `;` 17 | const datalinesEndPattern2 = `;;;;` 18 | 19 | describe('getDataSectionsDetail', () => { 20 | const config = new LintConfig(DefaultLintConfiguration) 21 | it(`should return the detail of data section when it begins and ends with '${datalinesBeginPattern1}' and '${datalinesEndPattern1}' markers`, () => { 22 | const text = `%put hello\n${datalinesBeginPattern1}\n${datalines}\n${datalinesEndPattern1}\n%put world;` 23 | expect(getDataSectionsDetail(text, config)).toEqual([ 24 | { 25 | start: 1, 26 | end: 5 27 | } 28 | ]) 29 | }) 30 | 31 | it(`should return the detail of data section when it begins and ends with '${datalinesBeginPattern2}' and '${datalinesEndPattern2}' markers`, () => { 32 | const text = `%put hello\n${datalinesBeginPattern2}\n${datalines}\n${datalinesEndPattern2}\n%put world;` 33 | expect(getDataSectionsDetail(text, config)).toEqual([ 34 | { 35 | start: 1, 36 | end: 5 37 | } 38 | ]) 39 | }) 40 | 41 | it(`should return the detail of data section when it begins and ends with '${datalinesBeginPattern3}' and '${datalinesEndPattern1}' markers`, () => { 42 | const text = `%put hello\n${datalinesBeginPattern3}\n${datalines}\n${datalinesEndPattern1}\n%put world;` 43 | expect(getDataSectionsDetail(text, config)).toEqual([ 44 | { 45 | start: 1, 46 | end: 5 47 | } 48 | ]) 49 | }) 50 | 51 | it(`should return the detail of data section when it begins and ends with '${datalinesBeginPattern4}' and '${datalinesEndPattern2}' markers`, () => { 52 | const text = `%put hello\n${datalinesBeginPattern4}\n${datalines}\n${datalinesEndPattern2}\n%put world;` 53 | expect(getDataSectionsDetail(text, config)).toEqual([ 54 | { 55 | start: 1, 56 | end: 5 57 | } 58 | ]) 59 | }) 60 | 61 | it(`should return the detail of data section when it begins and ends with '${datalinesBeginPattern5}' and '${datalinesEndPattern1}' markers`, () => { 62 | const text = `%put hello\n${datalinesBeginPattern5}\n${datalines}\n${datalinesEndPattern1}\n%put world;` 63 | expect(getDataSectionsDetail(text, config)).toEqual([ 64 | { 65 | start: 1, 66 | end: 5 67 | } 68 | ]) 69 | }) 70 | 71 | it(`should return the detail of data section when it begins and ends with '${datalinesBeginPattern6}' and '${datalinesEndPattern2}' markers`, () => { 72 | const text = `%put hello\n${datalinesBeginPattern6}\n${datalines}\n${datalinesEndPattern2}\n%put world;` 73 | expect(getDataSectionsDetail(text, config)).toEqual([ 74 | { 75 | start: 1, 76 | end: 5 77 | } 78 | ]) 79 | }) 80 | }) 81 | 82 | describe('checkIsDataLine', () => { 83 | const config = new LintConfig(DefaultLintConfiguration) 84 | it(`should return true if a line index is in a range of any data section`, () => { 85 | const text = `%put hello\n${datalinesBeginPattern1}\n${datalines}\n${datalinesEndPattern1}\n%put world;` 86 | expect( 87 | checkIsDataLine( 88 | [ 89 | { 90 | start: 1, 91 | end: 5 92 | } 93 | ], 94 | 4 95 | ) 96 | ).toBe(true) 97 | }) 98 | 99 | it(`should return false if a line index is not in a range of any of data sections`, () => { 100 | const text = `%put hello\n${datalinesBeginPattern1}\n${datalines}\n${datalinesEndPattern1}\n%put world;` 101 | expect( 102 | checkIsDataLine( 103 | [ 104 | { 105 | start: 1, 106 | end: 5 107 | } 108 | ], 109 | 8 110 | ) 111 | ).toBe(false) 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /src/rules/file/hasMacroParentheses.spec.ts: -------------------------------------------------------------------------------- 1 | import { Severity } from '../../types/Severity' 2 | import { hasMacroParentheses } from './hasMacroParentheses' 3 | 4 | describe('hasMacroParentheses', () => { 5 | it('should return an empty array when macro defined correctly', () => { 6 | const content = ` 7 | %macro somemacro(); 8 | %put &sysmacroname; 9 | %mend somemacro;` 10 | 11 | expect(hasMacroParentheses.test(content)).toEqual([]) 12 | }) 13 | 14 | it('should return an array with a single diagnostics when macro defined without parentheses', () => { 15 | const content = ` 16 | %macro somemacro; 17 | %put &sysmacroname; 18 | %mend somemacro;` 19 | expect(hasMacroParentheses.test(content)).toEqual([ 20 | { 21 | message: 'Macro definition missing parentheses', 22 | lineNumber: 2, 23 | startColumnNumber: 10, 24 | endColumnNumber: 18, 25 | severity: Severity.Warning 26 | } 27 | ]) 28 | }) 29 | 30 | it('should return an array with a single diagnostic when macro defined without name', () => { 31 | const content = ` 32 | %macro (); 33 | %put &sysmacroname; 34 | %mend;` 35 | 36 | expect(hasMacroParentheses.test(content)).toEqual([ 37 | { 38 | message: 'Macro definition missing name', 39 | lineNumber: 2, 40 | startColumnNumber: 3, 41 | endColumnNumber: 12, 42 | severity: Severity.Warning 43 | } 44 | ]) 45 | }) 46 | 47 | it('should return an array with a single diagnostic when macro defined without name ( single line code )', () => { 48 | const content = ` 49 | %macro (); %put &sysmacroname; %mend;` 50 | 51 | expect(hasMacroParentheses.test(content)).toEqual([ 52 | { 53 | message: 'Macro definition missing name', 54 | lineNumber: 2, 55 | startColumnNumber: 3, 56 | endColumnNumber: 12, 57 | severity: Severity.Warning 58 | } 59 | ]) 60 | }) 61 | 62 | it('should return an array with a single diagnostic when macro defined without name and parentheses', () => { 63 | const content = ` 64 | %macro ; 65 | %put &sysmacroname; 66 | %mend;` 67 | 68 | expect(hasMacroParentheses.test(content)).toEqual([ 69 | { 70 | message: 'Macro definition missing name', 71 | lineNumber: 2, 72 | startColumnNumber: 3, 73 | endColumnNumber: 9, 74 | severity: Severity.Warning 75 | } 76 | ]) 77 | }) 78 | 79 | it('should return an empty array when the file is undefined', () => { 80 | const content = undefined 81 | 82 | expect(hasMacroParentheses.test(content as unknown as string)).toEqual([]) 83 | }) 84 | 85 | describe('with extra spaces and comments', () => { 86 | it('should return an empty array when %mend has correct macro name', () => { 87 | const content = ` 88 | /* 1st comment */ 89 | %macro somemacro(); 90 | 91 | %put &sysmacroname; 92 | 93 | /* 2nd 94 | comment */ 95 | /* 3rd comment */ %mend somemacro ;` 96 | 97 | expect(hasMacroParentheses.test(content)).toEqual([]) 98 | }) 99 | 100 | it('should return an array with a single diagnostic when macro defined without parentheses having code in comments', () => { 101 | const content = `/** 102 | @file examplemacro.sas 103 | @brief an example of a macro to be used in a service 104 | @details This macro is great. Yadda yadda yadda. Usage: 105 | 106 | * code formatting applies when indented by 4 spaces; code formatting applies when indented by 4 spaces; code formatting applies when indented by 4 spaces; code formatting applies when indented by 4 spaces; code formatting applies when indented by 4 spaces; 107 | 108 | some code 109 | %macro examplemacro123(); 110 | 111 | %examplemacro() 112 | 113 |

SAS Macros

114 | @li doesnothing.sas 115 | 116 | @author Allan Bowe 117 | **/ 118 | 119 | %macro examplemacro; 120 | 121 | proc sql; 122 | create table areas 123 | as select area 124 | 125 | from sashelp.springs; 126 | 127 | %doesnothing(); 128 | 129 | %mend;` 130 | 131 | expect(hasMacroParentheses.test(content)).toEqual([ 132 | { 133 | message: 'Macro definition missing parentheses', 134 | lineNumber: 19, 135 | startColumnNumber: 12, 136 | endColumnNumber: 23, 137 | severity: Severity.Warning 138 | } 139 | ]) 140 | }) 141 | }) 142 | }) 143 | -------------------------------------------------------------------------------- /src/rules/file/hasRequiredMacroOptions.spec.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig, Severity } from '../../types' 2 | import { hasRequiredMacroOptions } from './hasRequiredMacroOptions' 3 | 4 | describe('hasRequiredMacroOptions - test', () => { 5 | it('should return an empty array when the content has the required macro option(s)', () => { 6 | const contentSecure = '%macro somemacro/ SECURE;' 7 | const configSecure = new LintConfig({ 8 | hasRequiredMacroOptions: true, 9 | requiredMacroOptions: ['SECURE'] 10 | }) 11 | expect(hasRequiredMacroOptions.test(contentSecure, configSecure)).toEqual( 12 | [] 13 | ) 14 | 15 | const contentSecureSrc = '%macro somemacro/ SECURE SRC;' 16 | const configSecureSrc = new LintConfig({ 17 | hasRequiredMacroOptions: true, 18 | requiredMacroOptions: ['SECURE', 'SRC'] 19 | }) 20 | expect( 21 | hasRequiredMacroOptions.test(contentSecureSrc, configSecureSrc) 22 | ).toEqual([]) 23 | 24 | const configEmpty = new LintConfig({ 25 | hasRequiredMacroOptions: true, 26 | requiredMacroOptions: [''] 27 | }) 28 | expect(hasRequiredMacroOptions.test(contentSecureSrc, configEmpty)).toEqual( 29 | [] 30 | ) 31 | }) 32 | 33 | it('should return an array with a single diagnostic when Macro does not contain the required option', () => { 34 | const configSecure = new LintConfig({ 35 | hasRequiredMacroOptions: true, 36 | requiredMacroOptions: ['SECURE'] 37 | }) 38 | 39 | const contentMinXOperator = '%macro somemacro(var1, var2)/minXoperator;' 40 | expect( 41 | hasRequiredMacroOptions.test(contentMinXOperator, configSecure) 42 | ).toEqual([ 43 | { 44 | message: `Macro 'somemacro' does not contain the required option 'SECURE'`, 45 | lineNumber: 1, 46 | startColumnNumber: 0, 47 | endColumnNumber: 0, 48 | severity: Severity.Warning 49 | } 50 | ]) 51 | 52 | const contentSecureSplit = '%macro somemacro(var1, var2)/ SE CURE;' 53 | expect( 54 | hasRequiredMacroOptions.test(contentSecureSplit, configSecure) 55 | ).toEqual([ 56 | { 57 | message: `Macro 'somemacro' does not contain the required option 'SECURE'`, 58 | lineNumber: 1, 59 | startColumnNumber: 0, 60 | endColumnNumber: 0, 61 | severity: Severity.Warning 62 | } 63 | ]) 64 | 65 | const contentNoOption = '%macro somemacro(var1, var2);' 66 | expect(hasRequiredMacroOptions.test(contentNoOption, configSecure)).toEqual( 67 | [ 68 | { 69 | message: `Macro 'somemacro' does not contain the required option 'SECURE'`, 70 | lineNumber: 1, 71 | startColumnNumber: 0, 72 | endColumnNumber: 0, 73 | severity: Severity.Warning 74 | } 75 | ] 76 | ) 77 | }) 78 | 79 | it('should return an array with a two diagnostics when Macro does not contain the required options', () => { 80 | const configSrcStmt = new LintConfig({ 81 | hasRequiredMacroOptions: true, 82 | requiredMacroOptions: ['SRC', 'STMT'], 83 | severityLevel: { hasRequiredMacroOptions: 'warn' } 84 | }) 85 | const contentMinXOperator = '%macro somemacro(var1, var2)/minXoperator;' 86 | expect( 87 | hasRequiredMacroOptions.test(contentMinXOperator, configSrcStmt) 88 | ).toEqual([ 89 | { 90 | message: `Macro 'somemacro' does not contain the required option 'SRC'`, 91 | lineNumber: 1, 92 | startColumnNumber: 0, 93 | endColumnNumber: 0, 94 | severity: Severity.Warning 95 | }, 96 | { 97 | message: `Macro 'somemacro' does not contain the required option 'STMT'`, 98 | lineNumber: 1, 99 | startColumnNumber: 0, 100 | endColumnNumber: 0, 101 | severity: Severity.Warning 102 | } 103 | ]) 104 | }) 105 | 106 | it('should return an array with a one diagnostic when Macro contains 1 of 2 required options', () => { 107 | const configSrcStmt = new LintConfig({ 108 | hasRequiredMacroOptions: true, 109 | requiredMacroOptions: ['SRC', 'STMT'], 110 | severityLevel: { hasRequiredMacroOptions: 'error' } 111 | }) 112 | const contentSrc = '%macro somemacro(var1, var2)/ SRC;' 113 | expect(hasRequiredMacroOptions.test(contentSrc, configSrcStmt)).toEqual([ 114 | { 115 | message: `Macro 'somemacro' does not contain the required option 'STMT'`, 116 | lineNumber: 1, 117 | startColumnNumber: 0, 118 | endColumnNumber: 0, 119 | severity: Severity.Error 120 | } 121 | ]) 122 | }) 123 | }) 124 | -------------------------------------------------------------------------------- /src/lint/lintFolder.spec.ts: -------------------------------------------------------------------------------- 1 | import { lintFolder } from './lintFolder' 2 | import { Severity } from '../types/Severity' 3 | import path from 'path' 4 | import { 5 | createFile, 6 | createFolder, 7 | deleteFolder, 8 | readFile 9 | } from '@sasjs/utils/file' 10 | 11 | const expectedFilesCount = 1 12 | const expectedDiagnostics = [ 13 | { 14 | message: 'Line contains trailing spaces', 15 | lineNumber: 1, 16 | startColumnNumber: 1, 17 | endColumnNumber: 2, 18 | severity: Severity.Warning 19 | }, 20 | { 21 | message: 'Line contains trailing spaces', 22 | lineNumber: 2, 23 | startColumnNumber: 1, 24 | endColumnNumber: 2, 25 | severity: Severity.Warning 26 | }, 27 | { 28 | message: 'File name contains spaces', 29 | lineNumber: 1, 30 | startColumnNumber: 1, 31 | endColumnNumber: 1, 32 | severity: Severity.Warning 33 | }, 34 | { 35 | message: 'File name contains uppercase characters', 36 | lineNumber: 1, 37 | startColumnNumber: 1, 38 | endColumnNumber: 1, 39 | severity: Severity.Warning 40 | }, 41 | { 42 | message: 'File missing Doxygen header', 43 | lineNumber: 1, 44 | startColumnNumber: 1, 45 | endColumnNumber: 1, 46 | severity: Severity.Warning 47 | }, 48 | { 49 | message: 'Line contains encoded password', 50 | lineNumber: 5, 51 | startColumnNumber: 10, 52 | endColumnNumber: 18, 53 | severity: Severity.Error 54 | }, 55 | { 56 | message: 'Line contains a tab character (09x)', 57 | lineNumber: 7, 58 | startColumnNumber: 1, 59 | endColumnNumber: 2, 60 | severity: Severity.Warning 61 | }, 62 | { 63 | message: 'Line has incorrect indentation - 3 spaces', 64 | lineNumber: 6, 65 | startColumnNumber: 1, 66 | endColumnNumber: 1, 67 | severity: Severity.Warning 68 | }, 69 | { 70 | message: '%mend statement is missing macro name - mf_getuniquelibref', 71 | lineNumber: 17, 72 | startColumnNumber: 3, 73 | endColumnNumber: 9, 74 | severity: Severity.Warning 75 | } 76 | ] 77 | 78 | describe('lintFolder', () => { 79 | it('should identify lint issues in a given folder', async () => { 80 | await createFolder(path.join(__dirname, 'lint-folder-test')) 81 | const content = await readFile( 82 | path.join(__dirname, '..', 'Example File.sas') 83 | ) 84 | await createFile( 85 | path.join(__dirname, 'lint-folder-test', 'Example File.sas'), 86 | content 87 | ) 88 | const results = await lintFolder(path.join(__dirname, 'lint-folder-test')) 89 | expect(results.size).toEqual(expectedFilesCount) 90 | const diagnostics = results.get( 91 | path.join(__dirname, 'lint-folder-test', 'Example File.sas') 92 | )! 93 | expect(diagnostics.length).toEqual(expectedDiagnostics.length) 94 | expect(diagnostics).toContainEqual(expectedDiagnostics[0]) 95 | expect(diagnostics).toContainEqual(expectedDiagnostics[1]) 96 | expect(diagnostics).toContainEqual(expectedDiagnostics[2]) 97 | expect(diagnostics).toContainEqual(expectedDiagnostics[3]) 98 | expect(diagnostics).toContainEqual(expectedDiagnostics[4]) 99 | expect(diagnostics).toContainEqual(expectedDiagnostics[5]) 100 | expect(diagnostics).toContainEqual(expectedDiagnostics[6]) 101 | expect(diagnostics).toContainEqual(expectedDiagnostics[7]) 102 | expect(diagnostics).toContainEqual(expectedDiagnostics[8]) 103 | 104 | await deleteFolder(path.join(__dirname, 'lint-folder-test')) 105 | }) 106 | 107 | it('should identify lint issues in subfolders of a given folder', async () => { 108 | await createFolder(path.join(__dirname, 'lint-folder-test')) 109 | await createFolder(path.join(__dirname, 'lint-folder-test', 'subfolder')) 110 | const content = await readFile( 111 | path.join(__dirname, '..', 'Example File.sas') 112 | ) 113 | await createFile( 114 | path.join(__dirname, 'lint-folder-test', 'subfolder', 'Example File.sas'), 115 | content 116 | ) 117 | const results = await lintFolder(path.join(__dirname, 'lint-folder-test')) 118 | expect(results.size).toEqual(expectedFilesCount) 119 | const diagnostics = results.get( 120 | path.join(__dirname, 'lint-folder-test', 'subfolder', 'Example File.sas') 121 | )! 122 | expect(diagnostics.length).toEqual(expectedDiagnostics.length) 123 | expect(diagnostics).toContainEqual(expectedDiagnostics[0]) 124 | expect(diagnostics).toContainEqual(expectedDiagnostics[1]) 125 | expect(diagnostics).toContainEqual(expectedDiagnostics[2]) 126 | expect(diagnostics).toContainEqual(expectedDiagnostics[3]) 127 | expect(diagnostics).toContainEqual(expectedDiagnostics[4]) 128 | expect(diagnostics).toContainEqual(expectedDiagnostics[5]) 129 | expect(diagnostics).toContainEqual(expectedDiagnostics[6]) 130 | expect(diagnostics).toContainEqual(expectedDiagnostics[7]) 131 | expect(diagnostics).toContainEqual(expectedDiagnostics[8]) 132 | 133 | await deleteFolder(path.join(__dirname, 'lint-folder-test')) 134 | }) 135 | }) 136 | -------------------------------------------------------------------------------- /src/rules/file/hasDoxygenHeader.spec.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig } from '../../types' 2 | import { Severity } from '../../types/Severity' 3 | import { hasDoxygenHeader } from './hasDoxygenHeader' 4 | 5 | describe('hasDoxygenHeader - test', () => { 6 | it('should return an empty array when the file starts with a doxygen header', () => { 7 | const content = `/** 8 | @file 9 | @brief Returns an unused libref 10 | **/ 11 | 12 | %macro mf_getuniquelibref(prefix=mclib,maxtries=1000); 13 | %local x libref; 14 | %let x={SAS002}; 15 | %do x=0 %to &maxtries;` 16 | 17 | expect(hasDoxygenHeader.test(content)).toEqual([]) 18 | }) 19 | 20 | it('should return an empty array when the file starts with a doxygen header', () => { 21 | const content = ` 22 | 23 | 24 | /* 25 | @file 26 | @brief Returns an unused libref 27 | */ 28 | 29 | %macro mf_getuniquelibref(prefix=mclib,maxtries=1000); 30 | %local x libref; 31 | %let x={SAS002}; 32 | %do x=0 %to &maxtries;` 33 | 34 | expect(hasDoxygenHeader.test(content)).toEqual([ 35 | { 36 | message: 37 | 'File not following Doxygen header style, use double asterisks', 38 | lineNumber: 4, 39 | startColumnNumber: 1, 40 | endColumnNumber: 1, 41 | severity: Severity.Warning 42 | } 43 | ]) 44 | }) 45 | 46 | it('should return an array with a single diagnostic when the file has no header', () => { 47 | const content = ` 48 | %macro mf_getuniquelibref(prefix=mclib,maxtries=1000); 49 | %local x libref; 50 | %let x={SAS002}; 51 | %do x=0 %to &maxtries;` 52 | 53 | expect(hasDoxygenHeader.test(content)).toEqual([ 54 | { 55 | message: 'File missing Doxygen header', 56 | lineNumber: 1, 57 | startColumnNumber: 1, 58 | endColumnNumber: 1, 59 | severity: Severity.Warning 60 | } 61 | ]) 62 | }) 63 | 64 | it('should return an array with a single diagnostic when the file has comment blocks but no header', () => { 65 | const content = ` 66 | %macro mf_getuniquelibref(prefix=mclib,maxtries=1000); 67 | %local x libref; 68 | %let x={SAS002}; 69 | /** Comment Line 1 70 | * Comment Line 2 71 | */ 72 | %do x=0 %to &maxtries;` 73 | 74 | expect(hasDoxygenHeader.test(content)).toEqual([ 75 | { 76 | message: 'File missing Doxygen header', 77 | lineNumber: 1, 78 | startColumnNumber: 1, 79 | endColumnNumber: 1, 80 | severity: Severity.Warning 81 | } 82 | ]) 83 | }) 84 | 85 | it('should return an array with a single diagnostic when the file is undefined', () => { 86 | const content = undefined 87 | 88 | expect(hasDoxygenHeader.test(content as unknown as string)).toEqual([ 89 | { 90 | message: 'File missing Doxygen header', 91 | lineNumber: 1, 92 | startColumnNumber: 1, 93 | endColumnNumber: 1, 94 | severity: Severity.Warning 95 | } 96 | ]) 97 | }) 98 | }) 99 | 100 | describe('hasDoxygenHeader - fix', () => { 101 | it('should not alter the text if a doxygen header is already present', () => { 102 | const content = `/** 103 | @file 104 | @brief Returns an unused libref 105 | **/ 106 | 107 | %macro mf_getuniquelibref(prefix=mclib,maxtries=1000); 108 | %local x libref; 109 | %let x={SAS002}; 110 | %do x=0 %to &maxtries;` 111 | 112 | expect(hasDoxygenHeader.fix!(content)).toEqual(content) 113 | }) 114 | 115 | it('should update single asterisks to double if a doxygen header is already present', () => { 116 | const contentOriginal = ` 117 | /* 118 | @file 119 | @brief Returns an unused libref 120 | */ 121 | 122 | %macro mf_getuniquelibref(prefix=mclib,maxtries=1000); 123 | %local x libref; 124 | %let x={SAS002}; 125 | %do x=0 %to &maxtries;` 126 | 127 | const contentExpected = ` 128 | /** 129 | @file 130 | @brief Returns an unused libref 131 | */ 132 | 133 | %macro mf_getuniquelibref(prefix=mclib,maxtries=1000); 134 | %local x libref; 135 | %let x={SAS002}; 136 | %do x=0 %to &maxtries;` 137 | 138 | expect(hasDoxygenHeader.fix!(contentOriginal)).toEqual(contentExpected) 139 | }) 140 | 141 | it('should add a doxygen header if not present', () => { 142 | const content = `%macro mf_getuniquelibref(prefix=mclib,maxtries=1000); 143 | %local x libref; 144 | %let x={SAS002}; 145 | %do x=0 %to &maxtries;` 146 | 147 | expect(hasDoxygenHeader.fix!(content)).toEqual( 148 | `/** 149 | @file 150 | @brief 151 |

SAS Macros

152 | **/` + 153 | '\n' + 154 | content 155 | ) 156 | }) 157 | 158 | it('should use CRLF line endings when configured', () => { 159 | const content = `%macro mf_getuniquelibref(prefix=mclib,maxtries=1000);\n%local x libref;\n%let x={SAS002};\n%do x=0 %to &maxtries;` 160 | const config = new LintConfig({ lineEndings: 'crlf' }) 161 | 162 | expect(hasDoxygenHeader.fix!(content, config)).toEqual( 163 | `/**\r\n @file\r\n @brief \r\n

SAS Macros

\r\n**/` + 164 | '\r\n' + 165 | content 166 | ) 167 | }) 168 | }) 169 | -------------------------------------------------------------------------------- /src/utils/parseMacros.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig, Macro } from '../types' 2 | import { LineEndings } from '../types/LineEndings' 3 | import { trimComments } from './trimComments' 4 | 5 | export const parseMacros = (text: string, config?: LintConfig): Macro[] => { 6 | const lineEnding = config?.lineEndings === LineEndings.CRLF ? '\r\n' : '\n' 7 | const lines: string[] = text ? text.split(lineEnding) : [] 8 | const macros: Macro[] = [] 9 | 10 | let isCommentStarted = false 11 | let macroStack: Macro[] = [] 12 | let isReadingMacroDefinition = false 13 | let isStatementContinues = true 14 | let tempMacroDeclaration = '' 15 | let tempMacroDeclarationLines: string[] = [] 16 | let tempStartLineNumbers: number[] = [] 17 | lines.forEach((line, lineIndex) => { 18 | const { statement: trimmedLine, commentStarted } = trimComments( 19 | line, 20 | isCommentStarted 21 | ) 22 | isCommentStarted = commentStarted 23 | 24 | isStatementContinues = !trimmedLine.endsWith(';') 25 | 26 | const statements: string[] = trimmedLine.split(';') 27 | 28 | if (isReadingMacroDefinition) { 29 | // checking if code is split into statements based on `;` is a part of HTML Encoded Character 30 | // if it happened, merges two statements into one 31 | statements.forEach((statement, statementIndex) => { 32 | if (/&[^\s]{1,5}$/.test(statement)) { 33 | const next = statements[statementIndex] 34 | const updatedStatement = `${statement};${ 35 | statements[statementIndex + 1] 36 | }` 37 | statements.splice(statementIndex, 1, updatedStatement) 38 | statements.splice(statementIndex + 1, 1) 39 | } 40 | }) 41 | } 42 | 43 | statements.forEach((statement, statementIndex) => { 44 | const { statement: trimmedStatement, commentStarted } = trimComments( 45 | statement, 46 | isCommentStarted 47 | ) 48 | isCommentStarted = commentStarted 49 | 50 | if (isReadingMacroDefinition) { 51 | tempMacroDeclaration = 52 | tempMacroDeclaration + 53 | (trimmedStatement ? ' ' + trimmedStatement : '') 54 | tempMacroDeclarationLines.push(line) 55 | tempStartLineNumbers.push(lineIndex + 1) 56 | 57 | if (!Object.is(statements.length - 1, statementIndex)) { 58 | isReadingMacroDefinition = false 59 | 60 | const name = tempMacroDeclaration 61 | .slice(7, tempMacroDeclaration.length) 62 | .trim() 63 | .split('/')[0] 64 | .split('(')[0] 65 | .trim() 66 | macroStack.push({ 67 | name, 68 | startLineNumbers: tempStartLineNumbers, 69 | endLineNumber: null, 70 | parentMacro: macroStack.length 71 | ? macroStack[macroStack.length - 1].name 72 | : '', 73 | hasMacroNameInMend: false, 74 | mismatchedMendMacroName: '', 75 | declarationLines: tempMacroDeclarationLines, 76 | terminationLine: '', 77 | declaration: tempMacroDeclaration, 78 | termination: '' 79 | }) 80 | } 81 | } 82 | 83 | if (trimmedStatement.startsWith('%macro')) { 84 | const startLineNumber = lineIndex + 1 85 | 86 | if ( 87 | isStatementContinues && 88 | Object.is(statements.length - 1, statementIndex) 89 | ) { 90 | tempMacroDeclaration = trimmedStatement 91 | tempMacroDeclarationLines = [line] 92 | tempStartLineNumbers = [startLineNumber] 93 | isReadingMacroDefinition = true 94 | return 95 | } 96 | 97 | const name = trimmedStatement 98 | .slice(7, trimmedStatement.length) 99 | .trim() 100 | .split('/')[0] 101 | .split('(')[0] 102 | .trim() 103 | macroStack.push({ 104 | name, 105 | startLineNumbers: [startLineNumber], 106 | endLineNumber: null, 107 | parentMacro: macroStack.length 108 | ? macroStack[macroStack.length - 1].name 109 | : '', 110 | hasMacroNameInMend: false, 111 | mismatchedMendMacroName: '', 112 | declarationLines: [line], 113 | terminationLine: '', 114 | declaration: trimmedStatement, 115 | termination: '' 116 | }) 117 | } else if (trimmedStatement.startsWith('%mend')) { 118 | if (macroStack.length) { 119 | const macro = macroStack.pop() as Macro 120 | const mendMacroName = 121 | trimmedStatement.split(' ').filter((s: string) => !!s)[1] || '' 122 | macro.endLineNumber = lineIndex + 1 123 | macro.hasMacroNameInMend = mendMacroName === macro.name 124 | macro.mismatchedMendMacroName = macro.hasMacroNameInMend 125 | ? '' 126 | : mendMacroName 127 | macro.terminationLine = line 128 | macro.termination = trimmedStatement 129 | macros.push(macro) 130 | } else { 131 | macros.push({ 132 | name: '', 133 | startLineNumbers: [], 134 | endLineNumber: lineIndex + 1, 135 | parentMacro: '', 136 | hasMacroNameInMend: false, 137 | mismatchedMendMacroName: '', 138 | declarationLines: [], 139 | terminationLine: line, 140 | declaration: '', 141 | termination: trimmedStatement 142 | }) 143 | } 144 | } 145 | }) 146 | }) 147 | 148 | macros.push(...macroStack) 149 | 150 | return macros 151 | } 152 | -------------------------------------------------------------------------------- /src/rules/file/lineEndings.spec.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig, Severity } from '../../types' 2 | import { LineEndings } from '../../types/LineEndings' 3 | import { lineEndings } from './lineEndings' 4 | 5 | describe('lineEndings - test', () => { 6 | it('should return an empty array when the text contains the configured line endings', () => { 7 | const text = "%put 'hello';\n%put 'world';\n" 8 | const config = new LintConfig({ lineEndings: LineEndings.LF }) 9 | expect(lineEndings.test(text, config)).toEqual([]) 10 | }) 11 | 12 | it('should return an array with a single diagnostic when a line is terminated with a CRLF ending', () => { 13 | const text = "%put 'hello';\n%put 'world';\r\n" 14 | const config = new LintConfig({ lineEndings: LineEndings.LF }) 15 | expect(lineEndings.test(text, config)).toEqual([ 16 | { 17 | message: 'Incorrect line ending - CRLF instead of LF', 18 | lineNumber: 2, 19 | startColumnNumber: 13, 20 | endColumnNumber: 14, 21 | severity: Severity.Warning 22 | } 23 | ]) 24 | }) 25 | 26 | it('should return an array with a single diagnostic when a line is terminated with an LF ending', () => { 27 | const text = "%put 'hello';\n%put 'world';\r\n" 28 | const config = new LintConfig({ lineEndings: LineEndings.CRLF }) 29 | expect(lineEndings.test(text, config)).toEqual([ 30 | { 31 | message: 'Incorrect line ending - LF instead of CRLF', 32 | lineNumber: 1, 33 | startColumnNumber: 13, 34 | endColumnNumber: 14, 35 | severity: Severity.Warning 36 | } 37 | ]) 38 | }) 39 | 40 | it('should return an array with a diagnostic for each line terminated with an LF ending', () => { 41 | const text = "%put 'hello';\n%put 'test';\r\n%put 'world';\n" 42 | const config = new LintConfig({ lineEndings: LineEndings.CRLF }) 43 | expect(lineEndings.test(text, config)).toContainEqual({ 44 | message: 'Incorrect line ending - LF instead of CRLF', 45 | lineNumber: 1, 46 | startColumnNumber: 13, 47 | endColumnNumber: 14, 48 | severity: Severity.Warning 49 | }) 50 | expect(lineEndings.test(text, config)).toContainEqual({ 51 | message: 'Incorrect line ending - LF instead of CRLF', 52 | lineNumber: 3, 53 | startColumnNumber: 13, 54 | endColumnNumber: 14, 55 | severity: Severity.Warning 56 | }) 57 | }) 58 | 59 | it('should return an array with a diagnostic for each line terminated with a CRLF ending', () => { 60 | const text = "%put 'hello';\r\n%put 'test';\n%put 'world';\r\n" 61 | const config = new LintConfig({ lineEndings: LineEndings.LF }) 62 | expect(lineEndings.test(text, config)).toContainEqual({ 63 | message: 'Incorrect line ending - CRLF instead of LF', 64 | lineNumber: 1, 65 | startColumnNumber: 13, 66 | endColumnNumber: 14, 67 | severity: Severity.Warning 68 | }) 69 | expect(lineEndings.test(text, config)).toContainEqual({ 70 | message: 'Incorrect line ending - CRLF instead of LF', 71 | lineNumber: 3, 72 | startColumnNumber: 13, 73 | endColumnNumber: 14, 74 | severity: Severity.Warning 75 | }) 76 | }) 77 | 78 | it('should return an array with a diagnostic for lines terminated with a CRLF ending', () => { 79 | const text = 80 | "%put 'hello';\r\n%put 'test';\r\n%put 'world';\n%put 'test2';\n%put 'world2';\r\n" 81 | const config = new LintConfig({ lineEndings: LineEndings.LF }) 82 | expect(lineEndings.test(text, config)).toContainEqual({ 83 | message: 'Incorrect line ending - CRLF instead of LF', 84 | lineNumber: 1, 85 | startColumnNumber: 13, 86 | endColumnNumber: 14, 87 | severity: Severity.Warning 88 | }) 89 | expect(lineEndings.test(text, config)).toContainEqual({ 90 | message: 'Incorrect line ending - CRLF instead of LF', 91 | lineNumber: 2, 92 | startColumnNumber: 12, 93 | endColumnNumber: 13, 94 | severity: Severity.Warning 95 | }) 96 | expect(lineEndings.test(text, config)).toContainEqual({ 97 | message: 'Incorrect line ending - CRLF instead of LF', 98 | lineNumber: 5, 99 | startColumnNumber: 14, 100 | endColumnNumber: 15, 101 | severity: Severity.Warning 102 | }) 103 | }) 104 | }) 105 | 106 | describe('lineEndings - fix', () => { 107 | it('should transform line endings to LF', () => { 108 | const text = 109 | "%put 'hello';\r\n%put 'test';\r\n%put 'world';\n%put 'test2';\n%put 'world2';\r\n" 110 | const config = new LintConfig({ lineEndings: LineEndings.LF }) 111 | 112 | const formattedText = lineEndings.fix!(text, config) 113 | 114 | expect(formattedText).toEqual( 115 | "%put 'hello';\n%put 'test';\n%put 'world';\n%put 'test2';\n%put 'world2';\n" 116 | ) 117 | }) 118 | 119 | it('should transform line endings to CRLF', () => { 120 | const text = 121 | "%put 'hello';\r\n%put 'test';\r\n%put 'world';\n%put 'test2';\n%put 'world2';\r\n" 122 | const config = new LintConfig({ lineEndings: LineEndings.CRLF }) 123 | 124 | const formattedText = lineEndings.fix!(text, config) 125 | 126 | expect(formattedText).toEqual( 127 | "%put 'hello';\r\n%put 'test';\r\n%put 'world';\r\n%put 'test2';\r\n%put 'world2';\r\n" 128 | ) 129 | }) 130 | 131 | it('should use LF line endings by default', () => { 132 | const text = 133 | "%put 'hello';\r\n%put 'test';\r\n%put 'world';\n%put 'test2';\n%put 'world2';\r\n" 134 | 135 | const formattedText = lineEndings.fix!(text) 136 | 137 | expect(formattedText).toEqual( 138 | "%put 'hello';\n%put 'test';\n%put 'world';\n%put 'test2';\n%put 'world2';\n" 139 | ) 140 | }) 141 | }) 142 | -------------------------------------------------------------------------------- /src/rules/file/hasMacroNameInMend.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic } from '../../types/Diagnostic' 2 | import { FileLintRule } from '../../types/LintRule' 3 | import { LintRuleType } from '../../types/LintRuleType' 4 | import { Severity } from '../../types/Severity' 5 | import { getColumnNumber } from '../../utils/getColumnNumber' 6 | import { LintConfig } from '../../types' 7 | import { LineEndings } from '../../types/LineEndings' 8 | import { parseMacros } from '../../utils/parseMacros' 9 | 10 | const name = 'hasMacroNameInMend' 11 | const description = 12 | 'Enforces the presence of the macro name in each %mend statement.' 13 | const message = '%mend statement has missing or incorrect macro name' 14 | 15 | const test = (value: string, config?: LintConfig) => { 16 | const lineEnding = config?.lineEndings === LineEndings.CRLF ? '\r\n' : '\n' 17 | const lines: string[] = value ? value.split(lineEnding) : [] 18 | const macros = parseMacros(value, config) 19 | const severity = config?.severityLevel[name] || Severity.Warning 20 | const diagnostics: Diagnostic[] = [] 21 | 22 | macros.forEach((macro) => { 23 | if (macro.startLineNumbers.length === 0 && macro.endLineNumber !== null) { 24 | const endLine = lines[macro.endLineNumber - 1] 25 | diagnostics.push({ 26 | message: `%mend statement is redundant`, 27 | lineNumber: macro.endLineNumber, 28 | startColumnNumber: getColumnNumber(endLine, '%mend'), 29 | endColumnNumber: 30 | getColumnNumber(endLine, '%mend') + macro.termination.length, 31 | severity 32 | }) 33 | } else if ( 34 | macro.endLineNumber === null && 35 | macro.startLineNumbers.length !== 0 36 | ) { 37 | diagnostics.push({ 38 | message: `Missing %mend statement for macro - ${macro.name}`, 39 | lineNumber: macro.startLineNumbers![0], 40 | startColumnNumber: 1, 41 | endColumnNumber: 1, 42 | severity 43 | }) 44 | } else if (macro.mismatchedMendMacroName) { 45 | const endLine = lines[(macro.endLineNumber as number) - 1] 46 | diagnostics.push({ 47 | message: `%mend statement has mismatched macro name, it should be '${ 48 | macro!.name 49 | }'`, 50 | lineNumber: macro.endLineNumber as number, 51 | startColumnNumber: getColumnNumber( 52 | endLine, 53 | macro.mismatchedMendMacroName 54 | ), 55 | endColumnNumber: 56 | getColumnNumber(endLine, macro.mismatchedMendMacroName) + 57 | macro.mismatchedMendMacroName.length - 58 | 1, 59 | severity 60 | }) 61 | } else if (!macro.hasMacroNameInMend) { 62 | const endLine = lines[(macro.endLineNumber as number) - 1] 63 | diagnostics.push({ 64 | message: `%mend statement is missing macro name - ${macro.name}`, 65 | lineNumber: macro.endLineNumber as number, 66 | startColumnNumber: getColumnNumber(endLine, '%mend'), 67 | endColumnNumber: getColumnNumber(endLine, '%mend') + 6, 68 | severity 69 | }) 70 | } 71 | }) 72 | 73 | return diagnostics 74 | } 75 | 76 | const fix = (value: string, config?: LintConfig): string => { 77 | const lineEnding = config?.lineEndings === LineEndings.CRLF ? '\r\n' : '\n' 78 | const lines: string[] = value ? value.split(lineEnding) : [] 79 | const macros = parseMacros(value, config) 80 | 81 | macros.forEach((macro) => { 82 | if (macro.startLineNumbers.length === 0 && macro.endLineNumber !== null) { 83 | // %mend statement is redundant 84 | const endLine = lines[macro.endLineNumber - 1] 85 | const startColumnNumber = getColumnNumber(endLine, '%mend') 86 | const endColumnNumber = 87 | getColumnNumber(endLine, '%mend') + macro.termination.length 88 | 89 | const beforeStatement = endLine.slice(0, startColumnNumber - 1) 90 | const afterStatement = endLine.slice(endColumnNumber) 91 | lines[macro.endLineNumber - 1] = beforeStatement + afterStatement 92 | } else if ( 93 | macro.endLineNumber === null && 94 | macro.startLineNumbers.length !== 0 95 | ) { 96 | // missing %mend statement 97 | } else if (macro.mismatchedMendMacroName) { 98 | // mismatched macro name 99 | const endLine = lines[(macro.endLineNumber as number) - 1] 100 | const startColumnNumber = getColumnNumber( 101 | endLine, 102 | macro.mismatchedMendMacroName 103 | ) 104 | const endColumnNumber = 105 | getColumnNumber(endLine, macro.mismatchedMendMacroName) + 106 | macro.mismatchedMendMacroName.length - 107 | 1 108 | 109 | const beforeMacroName = endLine.slice(0, startColumnNumber - 1) 110 | const afterMacroName = endLine.slice(endColumnNumber) 111 | 112 | lines[(macro.endLineNumber as number) - 1] = 113 | beforeMacroName + macro.name + afterMacroName 114 | } else if (!macro.hasMacroNameInMend) { 115 | // %mend statement is missing macro name 116 | const endLine = lines[(macro.endLineNumber as number) - 1] 117 | const startColumnNumber = getColumnNumber(endLine, '%mend') 118 | const endColumnNumber = getColumnNumber(endLine, '%mend') + 4 119 | 120 | const beforeStatement = endLine.slice(0, startColumnNumber - 1) 121 | const afterStatement = endLine.slice(endColumnNumber) 122 | lines[(macro.endLineNumber as number) - 1] = 123 | beforeStatement + `%mend ${macro.name}` + afterStatement 124 | } 125 | }) 126 | const formattedText = lines.join(lineEnding) 127 | 128 | return formattedText 129 | } 130 | 131 | /** 132 | * Lint rule that checks for the presence of macro name in %mend statement. 133 | */ 134 | export const hasMacroNameInMend: FileLintRule = { 135 | type: LintRuleType.File, 136 | name, 137 | description, 138 | message, 139 | test, 140 | fix 141 | } 142 | -------------------------------------------------------------------------------- /src/rules/file/strictMacroDefinition.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic, LintConfig, Macro, Severity } from '../../types' 2 | import { FileLintRule } from '../../types/LintRule' 3 | import { LintRuleType } from '../../types/LintRuleType' 4 | import { parseMacros } from '../../utils/parseMacros' 5 | 6 | const name = 'strictMacroDefinition' 7 | const description = 'Enforce strictly rules of macro definition syntax.' 8 | const message = 'Incorrent Macro Definition Syntax' 9 | 10 | const validOptions = [ 11 | 'CMD', 12 | 'DES', 13 | 'MINDELIMITER', 14 | 'MINOPERATOR', 15 | 'NOMINOPERATOR', 16 | 'PARMBUFF', 17 | 'SECURE', 18 | 'NOSECURE', 19 | 'STMT', 20 | 'SOURCE', 21 | 'SRC', 22 | 'STORE' 23 | ] 24 | 25 | const processParams = ( 26 | content: string, 27 | macro: Macro, 28 | diagnostics: Diagnostic[], 29 | config?: LintConfig 30 | ): string => { 31 | const declaration = macro.declaration 32 | const severity = config?.severityLevel[name] || Severity.Warning 33 | 34 | const regExpParams = new RegExp(/(?<=\().*(?=\))/) 35 | const regExpParamsResult = regExpParams.exec(declaration) 36 | 37 | let _declaration = declaration 38 | if (regExpParamsResult) { 39 | const paramsPresent = regExpParamsResult[0] 40 | 41 | const params = paramsPresent.trim().split(',') 42 | params.forEach((param) => { 43 | const trimedParam = param.split('=')[0].trim() 44 | 45 | let paramLineNumber: number = 1, 46 | paramStartIndex: number = 1, 47 | paramEndIndex: number = content.length 48 | 49 | if ( 50 | macro.declarationLines.findIndex( 51 | (dl) => dl.indexOf(trimedParam) !== -1 52 | ) === -1 53 | ) { 54 | const comment = '/\\*(.*?)\\*/' 55 | for (let i = 1; i < trimedParam.length; i++) { 56 | const paramWithComment = 57 | trimedParam.slice(0, i) + comment + trimedParam.slice(i) 58 | const regEx = new RegExp(paramWithComment) 59 | 60 | const declarationLineIndex = macro.declarationLines.findIndex( 61 | (dl) => !!regEx.exec(dl) 62 | ) 63 | 64 | if (declarationLineIndex !== -1) { 65 | const declarationLine = macro.declarationLines[declarationLineIndex] 66 | const partFound = regEx.exec(declarationLine)![0] 67 | 68 | paramLineNumber = macro.startLineNumbers[declarationLineIndex] 69 | paramStartIndex = declarationLine.indexOf(partFound) 70 | paramEndIndex = 71 | declarationLine.indexOf(partFound) + partFound.length 72 | break 73 | } 74 | } 75 | } else { 76 | const declarationLineIndex = macro.declarationLines.findIndex( 77 | (dl) => dl.indexOf(trimedParam) !== -1 78 | ) 79 | const declarationLine = macro.declarationLines[declarationLineIndex] 80 | paramLineNumber = macro.startLineNumbers[declarationLineIndex] 81 | 82 | paramStartIndex = declarationLine.indexOf(trimedParam) 83 | paramEndIndex = 84 | declarationLine.indexOf(trimedParam) + trimedParam.length 85 | } 86 | 87 | if (trimedParam.includes(' ')) { 88 | diagnostics.push({ 89 | message: `Param '${trimedParam}' cannot have space`, 90 | lineNumber: paramLineNumber, 91 | startColumnNumber: paramStartIndex + 1, 92 | endColumnNumber: paramEndIndex, 93 | severity 94 | }) 95 | } 96 | }) 97 | 98 | _declaration = declaration.split(`(${paramsPresent})`)[1] 99 | } 100 | return _declaration 101 | } 102 | 103 | const processOptions = ( 104 | _declaration: string, 105 | macro: Macro, 106 | diagnostics: Diagnostic[], 107 | config?: LintConfig 108 | ): void => { 109 | let optionsPresent = _declaration.split('/')?.[1]?.trim() 110 | const severity = config?.severityLevel[name] || Severity.Warning 111 | 112 | if (optionsPresent) { 113 | const regex = new RegExp(/=["|'](.*?)["|']/, 'g') 114 | 115 | let result = regex.exec(optionsPresent) 116 | 117 | // removing Option's `="..."` part, e.g. des="..." 118 | while (result) { 119 | optionsPresent = 120 | optionsPresent.slice(0, result.index) + 121 | optionsPresent.slice(result.index + result[0].length) 122 | 123 | result = regex.exec(optionsPresent) 124 | } 125 | 126 | optionsPresent 127 | .split(' ') 128 | ?.filter((o) => !!o) 129 | .forEach((option) => { 130 | const trimmedOption = option.trim() 131 | if (!validOptions.includes(trimmedOption.toUpperCase())) { 132 | const declarationLineIndex = macro.declarationLines.findIndex( 133 | (dl) => dl.indexOf(trimmedOption) !== -1 134 | ) 135 | const declarationLine = macro.declarationLines[declarationLineIndex] 136 | 137 | diagnostics.push({ 138 | message: `Option '${trimmedOption}' is not valid`, 139 | lineNumber: macro.startLineNumbers[declarationLineIndex], 140 | startColumnNumber: declarationLine.indexOf(trimmedOption) + 1, 141 | endColumnNumber: 142 | declarationLine.indexOf(trimmedOption) + trimmedOption.length, 143 | severity 144 | }) 145 | } 146 | }) 147 | } 148 | } 149 | 150 | const test = (value: string, config?: LintConfig) => { 151 | const diagnostics: Diagnostic[] = [] 152 | 153 | const macros = parseMacros(value, config) 154 | 155 | macros.forEach((macro) => { 156 | const _declaration = processParams(value, macro, diagnostics, config) 157 | 158 | processOptions(_declaration, macro, diagnostics, config) 159 | }) 160 | 161 | return diagnostics 162 | } 163 | 164 | /** 165 | * Lint rule that checks if a line has followed syntax for macro definition 166 | */ 167 | export const strictMacroDefinition: FileLintRule = { 168 | type: LintRuleType.File, 169 | name, 170 | description, 171 | message, 172 | test 173 | } 174 | -------------------------------------------------------------------------------- /src/types/LintConfig.ts: -------------------------------------------------------------------------------- 1 | import { 2 | hasDoxygenHeader, 3 | hasMacroNameInMend, 4 | noNestedMacros, 5 | hasMacroParentheses, 6 | lineEndings, 7 | strictMacroDefinition, 8 | hasRequiredMacroOptions 9 | } from '../rules/file' 10 | import { 11 | indentationMultiple, 12 | maxLineLength, 13 | noEncodedPasswords, 14 | noTabs, 15 | noTrailingSpaces, 16 | noGremlins 17 | } from '../rules/line' 18 | import { lowerCaseFileNames, noSpacesInFileNames } from '../rules/path' 19 | import { LineEndings } from './LineEndings' 20 | import { FileLintRule, LineLintRule, PathLintRule } from './LintRule' 21 | import { getDefaultHeader } from '../utils' 22 | import { Severity } from './Severity' 23 | 24 | /** 25 | * LintConfig is the logical representation of the .sasjslint file. 26 | * It exposes two sets of rules - one to be run against each line in a file, 27 | * and one to be run once per file. 28 | * 29 | * More types of rules, when available, will be added here. 30 | */ 31 | export class LintConfig { 32 | readonly ignoreList: string[] = [] 33 | readonly allowedGremlins: string[] = [] 34 | readonly lineLintRules: LineLintRule[] = [] 35 | readonly fileLintRules: FileLintRule[] = [] 36 | readonly pathLintRules: PathLintRule[] = [] 37 | readonly maxLineLength: number = 80 38 | readonly maxHeaderLineLength: number = 80 39 | readonly maxDataLineLength: number = 80 40 | readonly indentationMultiple: number = 2 41 | readonly lineEndings: LineEndings = LineEndings.LF 42 | readonly defaultHeader: string = getDefaultHeader() 43 | readonly severityLevel: { [key: string]: Severity } = {} 44 | readonly requiredMacroOptions: string[] = [] 45 | 46 | constructor(json?: any) { 47 | if (json?.ignoreList) { 48 | if (Array.isArray(json.ignoreList)) { 49 | json.ignoreList.forEach((item: any) => { 50 | if (typeof item === 'string') this.ignoreList.push(item) 51 | else 52 | throw new Error( 53 | `Property "ignoreList" has invalid type of values. It can contain only strings.` 54 | ) 55 | }) 56 | } else { 57 | throw new Error(`Property "ignoreList" can only be an array of strings`) 58 | } 59 | } 60 | 61 | if (json?.noTrailingSpaces !== false) { 62 | this.lineLintRules.push(noTrailingSpaces) 63 | } 64 | 65 | if (json?.noEncodedPasswords !== false) { 66 | this.lineLintRules.push(noEncodedPasswords) 67 | } 68 | 69 | this.lineLintRules.push(noTabs) 70 | if (json?.noTabs === false || json?.noTabIndentation === false) { 71 | this.lineLintRules.pop() 72 | } 73 | 74 | if (json?.maxLineLength > 0) { 75 | this.lineLintRules.push(maxLineLength) 76 | this.maxLineLength = json.maxLineLength 77 | 78 | if (!isNaN(json?.maxHeaderLineLength)) { 79 | this.maxHeaderLineLength = json.maxHeaderLineLength 80 | } 81 | 82 | if (!isNaN(json?.maxDataLineLength)) { 83 | this.maxDataLineLength = json.maxDataLineLength 84 | } 85 | } 86 | 87 | if (json?.lineEndings && json.lineEndings !== LineEndings.OFF) { 88 | if ( 89 | json.lineEndings !== LineEndings.LF && 90 | json.lineEndings !== LineEndings.CRLF 91 | ) { 92 | throw new Error( 93 | `Invalid value for lineEndings: can be ${LineEndings.LF} or ${LineEndings.CRLF}` 94 | ) 95 | } 96 | this.fileLintRules.push(lineEndings) 97 | this.lineEndings = json.lineEndings 98 | } 99 | 100 | this.lineLintRules.push(indentationMultiple) 101 | if (!isNaN(json?.indentationMultiple)) { 102 | this.indentationMultiple = json.indentationMultiple as number 103 | } 104 | 105 | if (json?.hasDoxygenHeader !== false) { 106 | this.fileLintRules.push(hasDoxygenHeader) 107 | } 108 | 109 | if (json?.defaultHeader) { 110 | this.defaultHeader = json.defaultHeader 111 | } 112 | 113 | if (json?.noSpacesInFileNames !== false) { 114 | this.pathLintRules.push(noSpacesInFileNames) 115 | } 116 | 117 | if (json?.lowerCaseFileNames !== false) { 118 | this.pathLintRules.push(lowerCaseFileNames) 119 | } 120 | 121 | if (json?.hasMacroNameInMend) { 122 | this.fileLintRules.push(hasMacroNameInMend) 123 | } 124 | 125 | if (json?.noNestedMacros !== false) { 126 | this.fileLintRules.push(noNestedMacros) 127 | } 128 | 129 | if (json?.hasMacroParentheses !== false) { 130 | this.fileLintRules.push(hasMacroParentheses) 131 | } 132 | 133 | if (json?.strictMacroDefinition !== false) { 134 | this.fileLintRules.push(strictMacroDefinition) 135 | } 136 | 137 | if (json?.hasRequiredMacroOptions) { 138 | this.fileLintRules.push(hasRequiredMacroOptions) 139 | 140 | if (json?.requiredMacroOptions) { 141 | if ( 142 | Array.isArray(json.requiredMacroOptions) && 143 | json.requiredMacroOptions.length > 0 144 | ) { 145 | json.requiredMacroOptions.forEach((item: any) => { 146 | if (typeof item === 'string') { 147 | this.requiredMacroOptions.push(item) 148 | } else { 149 | throw new Error( 150 | `Property "requiredMacroOptions" has invalid type of values. It can only contain strings.` 151 | ) 152 | } 153 | }) 154 | } else { 155 | throw new Error( 156 | `Property "requiredMacroOptions" can only be an array of strings.` 157 | ) 158 | } 159 | } 160 | } 161 | 162 | if (json?.noGremlins !== false) { 163 | this.lineLintRules.push(noGremlins) 164 | 165 | if (json?.allowedGremlins) { 166 | if (Array.isArray(json.allowedGremlins)) { 167 | json.allowedGremlins.forEach((item: any) => { 168 | if (typeof item === 'string' && /^0x[0-9a-f]{4}$/i.test(item)) 169 | this.allowedGremlins.push(item) 170 | else 171 | throw new Error( 172 | `Property "allowedGremlins" has invalid type of values. It can contain only strings of form hexcode like '["0x0080", "0x3000"]'` 173 | ) 174 | }) 175 | } else { 176 | throw new Error( 177 | `Property "allowedGremlins" can only be an array of strings of form hexcode like '["0x0080", "0x3000"]'` 178 | ) 179 | } 180 | } 181 | } 182 | 183 | if (json?.severityLevel) { 184 | for (const [rule, severity] of Object.entries(json.severityLevel)) { 185 | if (severity === 'warn') this.severityLevel[rule] = Severity.Warning 186 | if (severity === 'error') this.severityLevel[rule] = Severity.Error 187 | } 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/utils/gremlinCharacters.ts: -------------------------------------------------------------------------------- 1 | // Used https://compart.com/en/unicode to find the to find the description of each gremlin 2 | // List of gremlins was deduced from https://github.com/redoPop/SublimeGremlins/blob/main/Gremlins.py#L13 3 | 4 | export const gremlinCharacters = { 5 | '0x0003': { 6 | description: 'End of Text' 7 | }, 8 | '0x000b': { 9 | description: 'Line Tabulation' 10 | }, 11 | '0x007f': { 12 | description: 'Delete' 13 | }, 14 | '0x0080': { 15 | description: 'Padding' 16 | }, 17 | '0x0081': { 18 | description: 'High Octet Preset' 19 | }, 20 | '0x0082': { 21 | description: 'Break Permitted Here' 22 | }, 23 | '0x0083': { 24 | description: 'No Break Here' 25 | }, 26 | '0x0084': { 27 | description: 'Index' 28 | }, 29 | '0x0085': { 30 | description: 'Next Line' 31 | }, 32 | '0x0086': { 33 | description: 'Start of Selected Area' 34 | }, 35 | '0x0087': { 36 | description: 'End of Selected Area' 37 | }, 38 | '0x0088': { 39 | description: 'Character Tabulation Set' 40 | }, 41 | '0x0089': { 42 | description: 'Character Tabulation with Justification' 43 | }, 44 | '0x008a': { 45 | description: 'Line Tabulation Set' 46 | }, 47 | '0x008b': { 48 | description: 'Partial Line Down' 49 | }, 50 | '0x008c': { 51 | description: 'Partial Line Backward' 52 | }, 53 | '0x008d': { 54 | description: 'Reverse Index' 55 | }, 56 | '0x008e': { 57 | description: 'Single Shift Two' 58 | }, 59 | '0x008f': { 60 | description: 'Single Shift Three' 61 | }, 62 | '0x0090': { 63 | description: 'Device Control String' 64 | }, 65 | '0x0091': { 66 | description: 'Private Use One' 67 | }, 68 | '0x0092': { 69 | description: 'Private Use Two' 70 | }, 71 | '0x0093': { 72 | description: 'Set Transmit State' 73 | }, 74 | '0x0094': { 75 | description: 'Cancel Character' 76 | }, 77 | '0x0095': { 78 | description: 'Message Waiting' 79 | }, 80 | '0x0096': { 81 | description: 'Start of Guarded Area' 82 | }, 83 | '0x0097': { 84 | description: 'End of Guarded Area' 85 | }, 86 | '0x0098': { 87 | description: 'Start of String' 88 | }, 89 | '0x0099': { 90 | description: 'Single Graphic Character Introducer' 91 | }, 92 | '0x009a': { 93 | description: 'Single Character Introducer' 94 | }, 95 | '0x009b': { 96 | description: 'Control Sequence Introducer' 97 | }, 98 | '0x009c': { 99 | description: 'String Terminator' 100 | }, 101 | '0x009d': { 102 | description: 'Operating System Command' 103 | }, 104 | '0x009e': { 105 | description: 'Privacy Message' 106 | }, 107 | '0x009f': { 108 | description: 'Application Program Command' 109 | }, 110 | '0x00a0': { 111 | description: 'non breaking space' 112 | }, 113 | '0x00ad': { 114 | description: 'Soft Hyphen' 115 | }, 116 | '0x2000': { 117 | description: 'En Quad' 118 | }, 119 | '0x2001': { 120 | description: 'Em Quad' 121 | }, 122 | '0x2002': { 123 | description: 'En Space' 124 | }, 125 | '0x2003': { 126 | description: 'Em Space' 127 | }, 128 | '0x2004': { 129 | description: 'Three-Per-Em Space' 130 | }, 131 | '0x2005': { 132 | description: 'Four-Per-Em Space' 133 | }, 134 | '0x2006': { 135 | description: 'Six-Per-Em Space' 136 | }, 137 | '0x2007': { 138 | description: 'Figure Space' 139 | }, 140 | '0x2008': { 141 | description: 'Punctuation Space' 142 | }, 143 | '0x2009': { 144 | description: 'Thin Space' 145 | }, 146 | '0x200a': { 147 | description: 'Hair Space' 148 | }, 149 | '0x200b': { 150 | description: 'Zero Width Space' 151 | }, 152 | '0x200c': { 153 | description: 'Zero Width Non-Joiner' 154 | }, 155 | '0x200d': { 156 | description: 'Zero Width Joiner' 157 | }, 158 | '0x200e': { 159 | description: 'Left-to-Right Mark' 160 | }, 161 | '0x200f': { 162 | description: 'Right-to-Left Mark' 163 | }, 164 | '0x2013': { 165 | description: 'En Dash' 166 | }, 167 | '0x2018': { 168 | description: 'Left Single Quotation Mark' 169 | }, 170 | '0x2019': { 171 | description: 'Right Single Quotation Mark' 172 | }, 173 | '0x201c': { 174 | description: 'Left Double Quotation Mark' 175 | }, 176 | '0x201d': { 177 | description: 'Right Double Quotation Mark' 178 | }, 179 | '0x2028': { 180 | description: 'Line Separator' 181 | }, 182 | '0x2029': { 183 | description: 'Paragraph Separator' 184 | }, 185 | '0x202a': { 186 | description: 'Left-to-Right Embedding' 187 | }, 188 | '0x202b': { 189 | description: 'Right-to-Left Embedding' 190 | }, 191 | '0x202c': { 192 | description: 'Pop Directional Formatting' 193 | }, 194 | '0x202d': { 195 | description: 'Left-to-Right Override' 196 | }, 197 | '0x202e': { 198 | description: 'Right-to-Left Override' 199 | }, 200 | '0x202f': { 201 | description: 'Narrow No-Break Space' 202 | }, 203 | '0x205f': { 204 | description: 'Medium Mathematical Space' 205 | }, 206 | '0x2060': { 207 | description: 'Word Joiner' 208 | }, 209 | '0x2061': { 210 | description: 'Function Application' 211 | }, 212 | '0x2062': { 213 | description: 'Invisible Times' 214 | }, 215 | '0x2063': { 216 | description: 'Invisible Separator' 217 | }, 218 | '0x2064': { 219 | description: 'Invisible Plus' 220 | }, 221 | '0x2066': { 222 | description: 'Left-to-Right Isolate' 223 | }, 224 | '0x2067': { 225 | description: 'Right-to-Left Isolate' 226 | }, 227 | '0x2068': { 228 | description: 'First Strong Isolate ' 229 | }, 230 | '0x2069': { 231 | description: 'Pop Directional Isolate' 232 | }, 233 | '0x206a': { 234 | description: 'Inhibit Symmetric Swapping' 235 | }, 236 | '0x206b': { 237 | description: 'Activate Symmetric Swapping' 238 | }, 239 | '0x206c': { 240 | description: 'Inhibit Arabic Form Shaping' 241 | }, 242 | '0x206d': { 243 | description: 'Activate Arabic Form Shaping' 244 | }, 245 | '0x206e': { 246 | description: 'National Digit Shapes' 247 | }, 248 | '0x206f': { 249 | description: 'Nominal Digit Shapes' 250 | }, 251 | '0x2800': { 252 | description: 'Braille Pattern Blank' 253 | }, 254 | '0x3000': { 255 | description: 'Ideographic Space' 256 | }, 257 | '0x3164': { 258 | description: 'Hangul Filler' 259 | }, 260 | '0xfe00': { 261 | description: 'Variation Selector-1' 262 | }, 263 | '0xfe01': { 264 | description: 'Variation Selector-2' 265 | }, 266 | '0xfe02': { 267 | description: 'Variation Selector-3' 268 | }, 269 | '0xfe03': { 270 | description: 'Variation Selector-4' 271 | }, 272 | '0xfe04': { 273 | description: 'Variation Selector-5' 274 | }, 275 | '0xfe05': { 276 | description: 'Variation Selector-6' 277 | }, 278 | '0xfe06': { 279 | description: 'Variation Selector-7' 280 | }, 281 | '0xfe07': { 282 | description: 'Variation Selector-8' 283 | }, 284 | '0xfe08': { 285 | description: 'Variation Selector-9' 286 | }, 287 | '0xfe09': { 288 | description: 'Variation Selector-10' 289 | }, 290 | '0xfe0a': { 291 | description: 'Variation Selector-11' 292 | }, 293 | '0xfe0b': { 294 | description: 'Variation Selector-12 ' 295 | }, 296 | '0xfe0c': { 297 | description: 'Variation Selector-13' 298 | }, 299 | '0xfe0d': { 300 | description: 'Variation Selector-14' 301 | }, 302 | '0xfe0e': { 303 | description: 'Variation Selector-15' 304 | }, 305 | '0xfe0f': { 306 | description: 'Variation Selector-16' 307 | }, 308 | '0xfeff': { 309 | description: 'Zero Width No-Break Space' 310 | }, 311 | '0xfffc': { 312 | description: 'Object Replacement Character' 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /src/format/formatFolder.spec.ts: -------------------------------------------------------------------------------- 1 | import { formatFolder } from './formatFolder' 2 | import path from 'path' 3 | import { 4 | createFile, 5 | createFolder, 6 | deleteFolder, 7 | readFile 8 | } from '@sasjs/utils/file' 9 | import { Diagnostic, LintConfig } from '../types' 10 | 11 | describe('formatFolder', () => { 12 | it('should fix linting issues in a given folder', async () => { 13 | const content = `%macro somemacro(); \n%put 'hello';\n%mend;` 14 | const expectedContent = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;` 15 | const expectedResult = { 16 | updatedFilePaths: [ 17 | path.join(__dirname, 'format-folder-test', 'format-folder-test.sas') 18 | ], 19 | fixedDiagnosticsCount: 3, 20 | unfixedDiagnostics: new Map([ 21 | [ 22 | path.join(__dirname, 'format-folder-test', 'format-folder-test.sas'), 23 | [] 24 | ] 25 | ]) 26 | } 27 | await createFolder(path.join(__dirname, 'format-folder-test')) 28 | await createFile( 29 | path.join(__dirname, 'format-folder-test', 'format-folder-test.sas'), 30 | content 31 | ) 32 | 33 | const result = await formatFolder( 34 | path.join(__dirname, 'format-folder-test') 35 | ) 36 | const formattedContent = await readFile( 37 | path.join(__dirname, 'format-folder-test', 'format-folder-test.sas') 38 | ) 39 | 40 | expect(formattedContent).toEqual(expectedContent) 41 | expect(result).toEqual(expectedResult) 42 | 43 | await deleteFolder(path.join(__dirname, 'format-folder-test')) 44 | }) 45 | 46 | it('should fix linting issues in subfolders of a given folder', async () => { 47 | const content = `%macro somemacro(); \n%put 'hello';\n%mend;` 48 | const expectedContent = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;` 49 | const expectedResult = { 50 | updatedFilePaths: [ 51 | path.join( 52 | __dirname, 53 | 'format-folder-test', 54 | 'subfolder', 55 | 'format-folder-test.sas' 56 | ) 57 | ], 58 | fixedDiagnosticsCount: 3, 59 | unfixedDiagnostics: new Map([ 60 | [ 61 | path.join( 62 | __dirname, 63 | 'format-folder-test', 64 | 'subfolder', 65 | 'format-folder-test.sas' 66 | ), 67 | [] 68 | ] 69 | ]) 70 | } 71 | 72 | await createFolder(path.join(__dirname, 'format-folder-test')) 73 | await createFolder(path.join(__dirname, 'subfolder')) 74 | await createFile( 75 | path.join( 76 | __dirname, 77 | 'format-folder-test', 78 | 'subfolder', 79 | 'format-folder-test.sas' 80 | ), 81 | content 82 | ) 83 | 84 | const result = await formatFolder( 85 | path.join(__dirname, 'format-folder-test') 86 | ) 87 | const formattedContent = await readFile( 88 | path.join( 89 | __dirname, 90 | 'format-folder-test', 91 | 'subfolder', 92 | 'format-folder-test.sas' 93 | ) 94 | ) 95 | 96 | expect(result).toEqual(expectedResult) 97 | expect(formattedContent).toEqual(expectedContent) 98 | 99 | await deleteFolder(path.join(__dirname, 'format-folder-test')) 100 | }) 101 | 102 | it('should use a custom configuration when provided', async () => { 103 | const content = `%macro somemacro(); \n%put 'hello';\n%mend;` 104 | const expectedContent = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;` 105 | const expectedResult = { 106 | updatedFilePaths: [ 107 | path.join(__dirname, 'format-folder-test', 'format-folder-test.sas') 108 | ], 109 | fixedDiagnosticsCount: 3, 110 | unfixedDiagnostics: new Map([ 111 | [ 112 | path.join(__dirname, 'format-folder-test', 'format-folder-test.sas'), 113 | [] 114 | ] 115 | ]) 116 | } 117 | await createFolder(path.join(__dirname, 'format-folder-test')) 118 | await createFile( 119 | path.join(__dirname, 'format-folder-test', 'format-folder-test.sas'), 120 | content 121 | ) 122 | 123 | const result = await formatFolder( 124 | path.join(__dirname, 'format-folder-test'), 125 | new LintConfig({ 126 | lineEndings: 'crlf', 127 | hasMacroNameInMend: false, 128 | hasDoxygenHeader: true, 129 | noTrailingSpaces: true 130 | }) 131 | ) 132 | const formattedContent = await readFile( 133 | path.join(__dirname, 'format-folder-test', 'format-folder-test.sas') 134 | ) 135 | 136 | expect(formattedContent).toEqual(expectedContent) 137 | expect(result).toEqual(expectedResult) 138 | 139 | await deleteFolder(path.join(__dirname, 'format-folder-test')) 140 | }) 141 | 142 | it('should fix linting issues in subfolders of a given folder', async () => { 143 | const content = `%macro somemacro(); \n%put 'hello';\n%mend;` 144 | const expectedContent = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;` 145 | const expectedResult = { 146 | updatedFilePaths: [ 147 | path.join( 148 | __dirname, 149 | 'format-folder-test', 150 | 'subfolder', 151 | 'format-folder-test.sas' 152 | ) 153 | ], 154 | fixedDiagnosticsCount: 3, 155 | unfixedDiagnostics: new Map([ 156 | [ 157 | path.join( 158 | __dirname, 159 | 'format-folder-test', 160 | 'subfolder', 161 | 'format-folder-test.sas' 162 | ), 163 | [] 164 | ] 165 | ]) 166 | } 167 | 168 | await createFolder(path.join(__dirname, 'format-folder-test')) 169 | await createFolder(path.join(__dirname, 'subfolder')) 170 | await createFile( 171 | path.join( 172 | __dirname, 173 | 'format-folder-test', 174 | 'subfolder', 175 | 'format-folder-test.sas' 176 | ), 177 | content 178 | ) 179 | 180 | const result = await formatFolder( 181 | path.join(__dirname, 'format-folder-test') 182 | ) 183 | const formattedContent = await readFile( 184 | path.join( 185 | __dirname, 186 | 'format-folder-test', 187 | 'subfolder', 188 | 'format-folder-test.sas' 189 | ) 190 | ) 191 | 192 | expect(result).toEqual(expectedResult) 193 | expect(formattedContent).toEqual(expectedContent) 194 | 195 | await deleteFolder(path.join(__dirname, 'format-folder-test')) 196 | }) 197 | 198 | it('should not update any files when there are no violations', async () => { 199 | const content = `/**\n @file\n @brief \n

SAS Macros

\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;` 200 | const expectedResult = { 201 | updatedFilePaths: [], 202 | fixedDiagnosticsCount: 0, 203 | unfixedDiagnostics: new Map([ 204 | [ 205 | path.join(__dirname, 'format-folder-test', 'format-folder-test.sas'), 206 | [] 207 | ] 208 | ]) 209 | } 210 | await createFolder(path.join(__dirname, 'format-folder-test')) 211 | await createFile( 212 | path.join(__dirname, 'format-folder-test', 'format-folder-test.sas'), 213 | content 214 | ) 215 | 216 | const result = await formatFolder( 217 | path.join(__dirname, 'format-folder-test') 218 | ) 219 | const formattedContent = await readFile( 220 | path.join(__dirname, 'format-folder-test', 'format-folder-test.sas') 221 | ) 222 | 223 | expect(formattedContent).toEqual(content) 224 | expect(result).toEqual(expectedResult) 225 | 226 | await deleteFolder(path.join(__dirname, 'format-folder-test')) 227 | }) 228 | }) 229 | -------------------------------------------------------------------------------- /src/rules/file/strictMacroDefinition.spec.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig, Severity } from '../../types' 2 | import { strictMacroDefinition } from './strictMacroDefinition' 3 | 4 | describe('strictMacroDefinition', () => { 5 | it('should return an empty array when the content has correct macro definition syntax', () => { 6 | const content = '%macro somemacro;' 7 | expect(strictMacroDefinition.test(content)).toEqual([]) 8 | 9 | const content2 = '%macro somemacro();' 10 | expect(strictMacroDefinition.test(content2)).toEqual([]) 11 | 12 | const content3 = '%macro somemacro(var1);' 13 | expect(strictMacroDefinition.test(content3)).toEqual([]) 14 | 15 | const content4 = '%macro somemacro/minoperator;' 16 | expect(strictMacroDefinition.test(content4)).toEqual([]) 17 | 18 | const content5 = '%macro somemacro /minoperator;' 19 | expect(strictMacroDefinition.test(content5)).toEqual([]) 20 | 21 | const content6 = '%macro somemacro(var1, var2)/minoperator;' 22 | expect(strictMacroDefinition.test(content6)).toEqual([]) 23 | 24 | const content7 = 25 | ' /* Some Comment */ %macro somemacro(var1, var2) /minoperator ; /* Some Comment */' 26 | expect(strictMacroDefinition.test(content7)).toEqual([]) 27 | 28 | const content8 = 29 | '%macro macroName( arr, arr/* / store source */3 ) /* / store source */;/* / store source */' 30 | expect(strictMacroDefinition.test(content8)).toEqual([]) 31 | 32 | const content9 = '%macro macroName(var1, var2=with space, var3=);' 33 | expect(strictMacroDefinition.test(content9)).toEqual([]) 34 | 35 | const content10 = '%macro macroName()/ /* some comment */ store source;' 36 | expect(strictMacroDefinition.test(content10)).toEqual([]) 37 | 38 | const content11 = '`%macro macroName() /* / store source */;' 39 | expect(strictMacroDefinition.test(content11)).toEqual([]) 40 | 41 | const content12 = 42 | '%macro macroName()/ /* some comment */ store des="some description";' 43 | expect(strictMacroDefinition.test(content12)).toEqual([]) 44 | }) 45 | 46 | it('should return an array with a single diagnostic when Macro definition has space in param', () => { 47 | const content = '%macro somemacro(va r1);' 48 | expect(strictMacroDefinition.test(content)).toEqual([ 49 | { 50 | message: `Param 'va r1' cannot have space`, 51 | lineNumber: 1, 52 | startColumnNumber: 18, 53 | endColumnNumber: 22, 54 | severity: Severity.Warning 55 | } 56 | ]) 57 | }) 58 | 59 | it('should return an array with a two diagnostics when Macro definition has space in params', () => { 60 | const content = '%macro somemacro(var1, var 2, v ar3, var4);' 61 | expect(strictMacroDefinition.test(content)).toEqual([ 62 | { 63 | message: `Param 'var 2' cannot have space`, 64 | lineNumber: 1, 65 | startColumnNumber: 24, 66 | endColumnNumber: 28, 67 | severity: Severity.Warning 68 | }, 69 | { 70 | message: `Param 'v ar3' cannot have space`, 71 | lineNumber: 1, 72 | startColumnNumber: 31, 73 | endColumnNumber: 35, 74 | severity: Severity.Warning 75 | } 76 | ]) 77 | }) 78 | 79 | it('should return an array with a two diagnostics when Macro definition has space in params - special case', () => { 80 | const content = 81 | '%macro macroName( arr, ar r/* / store source */ 3 ) /* / store source */;/* / store source */' 82 | expect(strictMacroDefinition.test(content)).toEqual([ 83 | { 84 | message: `Param 'ar r 3' cannot have space`, 85 | lineNumber: 1, 86 | startColumnNumber: 24, 87 | endColumnNumber: 49, 88 | severity: Severity.Warning 89 | } 90 | ]) 91 | }) 92 | 93 | it('should return an array with a single diagnostic when Macro definition has invalid option', () => { 94 | const content = '%macro somemacro(var1, var2)/minXoperator;' 95 | expect(strictMacroDefinition.test(content)).toEqual([ 96 | { 97 | message: `Option 'minXoperator' is not valid`, 98 | lineNumber: 1, 99 | startColumnNumber: 30, 100 | endColumnNumber: 41, 101 | severity: Severity.Warning 102 | } 103 | ]) 104 | }) 105 | 106 | it('should return an array with a two diagnostics when Macro definition has invalid options', () => { 107 | const content = 108 | '%macro somemacro(var1, var2)/ store invalidoption secure ;' 109 | expect(strictMacroDefinition.test(content)).toEqual([ 110 | { 111 | message: `Option 'invalidoption' is not valid`, 112 | lineNumber: 1, 113 | startColumnNumber: 39, 114 | endColumnNumber: 51, 115 | severity: Severity.Warning 116 | } 117 | ]) 118 | }) 119 | 120 | describe('multi-content macro declarations', () => { 121 | it('should return an empty array when the content has correct macro definition syntax', () => { 122 | const content = `%macro mp_ds2cards(base_ds=, tgt_ds=\n ,cards_file="%sysfunc(pathname(work))/cardgen.sas"\n ,maxobs=max\n ,random_sample=NO\n ,showlog=YES\n ,outencoding=\n ,append=NO\n)/*/STORE SOURCE*/;` 123 | expect(strictMacroDefinition.test(content)).toEqual([]) 124 | 125 | const content2 = `%macro mm_createapplication(\n tree=/User Folders/sasdemo\n ,name=myApp\n ,ClassIdentifier=mcore\n ,desc=Created by mm_createapplication\n ,params= param1=1 param2=blah\n ,version=\n ,frefin=mm_in\n ,frefout=mm_out\n ,mDebug=1\n );` 126 | expect(strictMacroDefinition.test(content2)).toEqual([]) 127 | }) 128 | 129 | it('should return an array with a single diagnostic when Macro definition has space in param', () => { 130 | const content = `%macro 131 | somemacro(va r1);` 132 | expect(strictMacroDefinition.test(content)).toEqual([ 133 | { 134 | message: `Param 'va r1' cannot have space`, 135 | lineNumber: 2, 136 | startColumnNumber: 18, 137 | endColumnNumber: 22, 138 | severity: Severity.Warning 139 | } 140 | ]) 141 | }) 142 | 143 | it('should return an array with a two diagnostics when Macro definition has space in params', () => { 144 | const content = `%macro somemacro( 145 | var1, 146 | var 2, 147 | v ar3, 148 | var4);` 149 | expect(strictMacroDefinition.test(content)).toEqual([ 150 | { 151 | message: `Param 'var 2' cannot have space`, 152 | lineNumber: 3, 153 | startColumnNumber: 7, 154 | endColumnNumber: 11, 155 | severity: Severity.Warning 156 | }, 157 | { 158 | message: `Param 'v ar3' cannot have space`, 159 | lineNumber: 4, 160 | startColumnNumber: 7, 161 | endColumnNumber: 11, 162 | severity: Severity.Warning 163 | } 164 | ]) 165 | }) 166 | 167 | it('should return an array with a two diagnostics when Macro definition has space in params - special case', () => { 168 | const content = `%macro macroName( 169 | arr, 170 | ar r/* / store source */ 3 171 | ) /* / store source */;/* / store source */` 172 | expect(strictMacroDefinition.test(content)).toEqual([ 173 | { 174 | message: `Param 'ar r 3' cannot have space`, 175 | lineNumber: 3, 176 | startColumnNumber: 7, 177 | endColumnNumber: 32, 178 | severity: Severity.Warning 179 | } 180 | ]) 181 | }) 182 | 183 | it('should return an array with a single diagnostic when Macro definition has invalid option', () => { 184 | const content = `%macro somemacro(var1, var2) 185 | /minXoperator;` 186 | expect(strictMacroDefinition.test(content)).toEqual([ 187 | { 188 | message: `Option 'minXoperator' is not valid`, 189 | lineNumber: 2, 190 | startColumnNumber: 8, 191 | endColumnNumber: 19, 192 | severity: Severity.Warning 193 | } 194 | ]) 195 | }) 196 | 197 | it('should return an array with a two diagnostics when Macro definition has invalid options', () => { 198 | const content = `%macro 199 | somemacro( 200 | var1, var2 201 | ) 202 | / store 203 | invalidoption 204 | secure ;` 205 | expect(strictMacroDefinition.test(content)).toEqual([ 206 | { 207 | message: `Option 'invalidoption' is not valid`, 208 | lineNumber: 6, 209 | startColumnNumber: 16, 210 | endColumnNumber: 28, 211 | severity: Severity.Warning 212 | } 213 | ]) 214 | }) 215 | }) 216 | }) 217 | -------------------------------------------------------------------------------- /src/types/LintConfig.spec.ts: -------------------------------------------------------------------------------- 1 | import { hasRequiredMacroOptions } from '../rules/file' 2 | import { LineEndings } from './LineEndings' 3 | import { LintConfig } from './LintConfig' 4 | import { LintRuleType } from './LintRuleType' 5 | import { Severity } from './Severity' 6 | 7 | describe('LintConfig', () => { 8 | it('should create an instance with default values when no configuration is provided', () => { 9 | const config = new LintConfig() 10 | expect(config).toBeTruthy() 11 | }) 12 | 13 | it('should create an instance with the noTrailingSpaces flag off', () => { 14 | const config = new LintConfig({ noTrailingSpaces: false }) 15 | 16 | expect(config).toBeTruthy() 17 | expect(config.lineLintRules.length).toBeGreaterThan(0) 18 | expect(config.fileLintRules.length).toBeGreaterThan(0) 19 | expect( 20 | config.lineLintRules.find((rule) => rule.name === 'noTrailingSpaces') 21 | ).toBeUndefined() 22 | }) 23 | 24 | it('should create an instance with the noEncodedPasswords flag off', () => { 25 | const config = new LintConfig({ noEncodedPasswords: false }) 26 | 27 | expect(config).toBeTruthy() 28 | expect(config.lineLintRules.length).toBeGreaterThan(0) 29 | expect(config.fileLintRules.length).toBeGreaterThan(0) 30 | expect( 31 | config.lineLintRules.find((rule) => rule.name === 'noEncodedPasswords') 32 | ).toBeUndefined() 33 | }) 34 | 35 | it('should create an instance with the maxLineLength flag off by setting value to 0', () => { 36 | const config = new LintConfig({ maxLineLength: 0 }) 37 | 38 | expect(config).toBeTruthy() 39 | expect(config.lineLintRules.length).toBeGreaterThan(0) 40 | expect(config.fileLintRules.length).toBeGreaterThan(0) 41 | expect( 42 | config.lineLintRules.find((rule) => rule.name === 'maxLineLength') 43 | ).toBeUndefined() 44 | }) 45 | 46 | it('should create an instance with the maxLineLength flag off by setting value to a negative number', () => { 47 | const config = new LintConfig({ maxLineLength: -1 }) 48 | 49 | expect(config).toBeTruthy() 50 | expect(config.lineLintRules.length).toBeGreaterThan(0) 51 | expect(config.fileLintRules.length).toBeGreaterThan(0) 52 | expect( 53 | config.lineLintRules.find((rule) => rule.name === 'maxLineLength') 54 | ).toBeUndefined() 55 | }) 56 | 57 | it('should create an instance with the hasDoxygenHeader flag off', () => { 58 | const config = new LintConfig({ hasDoxygenHeader: false }) 59 | 60 | expect(config).toBeTruthy() 61 | expect(config.lineLintRules.length).toBeGreaterThan(0) 62 | expect(config.fileLintRules.length).toBeGreaterThan(0) 63 | expect( 64 | config.fileLintRules.find((rule) => rule.name === 'hasDoxygenHeader') 65 | ).toBeUndefined() 66 | }) 67 | 68 | it('should create an instance with the hasMacroNameInMend flag off', () => { 69 | const config = new LintConfig({ hasMacroNameInMend: false }) 70 | 71 | expect(config).toBeTruthy() 72 | expect(config.lineLintRules.length).toBeGreaterThan(0) 73 | expect(config.fileLintRules.length).toBeGreaterThan(0) 74 | expect( 75 | config.fileLintRules.find((rule) => rule.name === 'hasMacroNameInMend') 76 | ).toBeUndefined() 77 | }) 78 | 79 | it('should create an instance with the noNestedMacros flag off', () => { 80 | const config = new LintConfig({ noNestedMacros: false }) 81 | 82 | expect(config).toBeTruthy() 83 | expect(config.lineLintRules.length).toBeGreaterThan(0) 84 | expect(config.fileLintRules.length).toBeGreaterThan(0) 85 | expect( 86 | config.fileLintRules.find((rule) => rule.name === 'noNestedMacros') 87 | ).toBeUndefined() 88 | }) 89 | 90 | it('should create an instance with the hasMacroParentheses flag off', () => { 91 | const config = new LintConfig({ hasMacroParentheses: false }) 92 | 93 | expect(config).toBeTruthy() 94 | expect(config.lineLintRules.length).toBeGreaterThan(0) 95 | expect(config.fileLintRules.length).toBeGreaterThan(0) 96 | expect( 97 | config.fileLintRules.find((rule) => rule.name === 'hasMacroParentheses') 98 | ).toBeUndefined() 99 | }) 100 | 101 | it('should create an instance with the indentation multiple set', () => { 102 | const config = new LintConfig({ indentationMultiple: 5 }) 103 | 104 | expect(config).toBeTruthy() 105 | expect(config.indentationMultiple).toEqual(5) 106 | }) 107 | 108 | it('should create an instance with the indentation multiple turned off', () => { 109 | const config = new LintConfig({ indentationMultiple: 0 }) 110 | 111 | expect(config).toBeTruthy() 112 | expect(config.indentationMultiple).toEqual(0) 113 | }) 114 | 115 | it('should create an instance with the line endings set to LF', () => { 116 | const config = new LintConfig({ lineEndings: 'lf' }) 117 | 118 | expect(config).toBeTruthy() 119 | expect(config.lineEndings).toEqual(LineEndings.LF) 120 | }) 121 | 122 | it('should create an instance with the line endings set to CRLF', () => { 123 | const config = new LintConfig({ lineEndings: 'crlf' }) 124 | 125 | expect(config).toBeTruthy() 126 | expect(config.lineEndings).toEqual(LineEndings.CRLF) 127 | }) 128 | 129 | it('should create an instance with the severityLevel config', () => { 130 | const config = new LintConfig({ 131 | severityLevel: { 132 | hasDoxygenHeader: 'warn', 133 | maxLineLength: 'error', 134 | noTrailingSpaces: 'error' 135 | } 136 | }) 137 | 138 | expect(config).toBeTruthy() 139 | expect(config.severityLevel).toEqual({ 140 | hasDoxygenHeader: Severity.Warning, 141 | maxLineLength: Severity.Error, 142 | noTrailingSpaces: Severity.Error 143 | }) 144 | }) 145 | 146 | it('should create an instance with the line endings set to LF by default', () => { 147 | const config = new LintConfig({}) 148 | 149 | expect(config).toBeTruthy() 150 | expect(config.lineEndings).toEqual(LineEndings.LF) 151 | }) 152 | 153 | it('should throw an error with an invalid value for line endings', () => { 154 | expect(() => new LintConfig({ lineEndings: 'test' })).toThrowError( 155 | `Invalid value for lineEndings: can be ${LineEndings.LF} or ${LineEndings.CRLF}` 156 | ) 157 | }) 158 | 159 | it('should create an instance with all flags set', () => { 160 | const config = new LintConfig({ 161 | noTrailingSpaces: true, 162 | noEncodedPasswords: true, 163 | hasDoxygenHeader: true, 164 | noSpacesInFileNames: true, 165 | lowerCaseFileNames: true, 166 | maxLineLength: 80, 167 | noTabIndentation: true, 168 | indentationMultiple: 2, 169 | hasMacroNameInMend: true, 170 | noNestedMacros: true, 171 | hasMacroParentheses: true, 172 | hasRequiredMacroOptions: true, 173 | noGremlins: true, 174 | lineEndings: 'lf' 175 | }) 176 | 177 | expect(config).toBeTruthy() 178 | expect(config.lineLintRules.length).toEqual(6) 179 | expect(config.lineLintRules[0].name).toEqual('noTrailingSpaces') 180 | expect(config.lineLintRules[0].type).toEqual(LintRuleType.Line) 181 | expect(config.lineLintRules[1].name).toEqual('noEncodedPasswords') 182 | expect(config.lineLintRules[1].type).toEqual(LintRuleType.Line) 183 | expect(config.lineLintRules[2].name).toEqual('noTabs') 184 | expect(config.lineLintRules[2].type).toEqual(LintRuleType.Line) 185 | expect(config.lineLintRules[3].name).toEqual('maxLineLength') 186 | expect(config.lineLintRules[3].type).toEqual(LintRuleType.Line) 187 | expect(config.lineLintRules[4].name).toEqual('indentationMultiple') 188 | expect(config.lineLintRules[4].type).toEqual(LintRuleType.Line) 189 | expect(config.lineLintRules[5].name).toEqual('noGremlins') 190 | expect(config.lineLintRules[5].type).toEqual(LintRuleType.Line) 191 | 192 | expect(config.fileLintRules.length).toEqual(7) 193 | expect(config.fileLintRules[0].name).toEqual('lineEndings') 194 | expect(config.fileLintRules[0].type).toEqual(LintRuleType.File) 195 | expect(config.fileLintRules[1].name).toEqual('hasDoxygenHeader') 196 | expect(config.fileLintRules[1].type).toEqual(LintRuleType.File) 197 | expect(config.fileLintRules[2].name).toEqual('hasMacroNameInMend') 198 | expect(config.fileLintRules[2].type).toEqual(LintRuleType.File) 199 | expect(config.fileLintRules[3].name).toEqual('noNestedMacros') 200 | expect(config.fileLintRules[3].type).toEqual(LintRuleType.File) 201 | expect(config.fileLintRules[4].name).toEqual('hasMacroParentheses') 202 | expect(config.fileLintRules[4].type).toEqual(LintRuleType.File) 203 | expect(config.fileLintRules[5].name).toEqual('strictMacroDefinition') 204 | expect(config.fileLintRules[5].type).toEqual(LintRuleType.File) 205 | expect(config.fileLintRules[6].name).toEqual('hasRequiredMacroOptions') 206 | expect(config.fileLintRules[6].type).toEqual(LintRuleType.File) 207 | 208 | expect(config.pathLintRules.length).toEqual(2) 209 | expect(config.pathLintRules[0].name).toEqual('noSpacesInFileNames') 210 | expect(config.pathLintRules[0].type).toEqual(LintRuleType.Path) 211 | expect(config.pathLintRules[1].name).toEqual('lowerCaseFileNames') 212 | expect(config.pathLintRules[1].type).toEqual(LintRuleType.Path) 213 | }) 214 | 215 | it('should throw an error with an invalid value for requiredMacroOptions', () => { 216 | expect( 217 | () => 218 | new LintConfig({ 219 | hasRequiredMacroOptions: true, 220 | requiredMacroOptions: 'test' 221 | }) 222 | ).toThrowError( 223 | `Property "requiredMacroOptions" can only be an array of strings.` 224 | ) 225 | expect( 226 | () => 227 | new LintConfig({ 228 | hasRequiredMacroOptions: true, 229 | requiredMacroOptions: ['test', 2] 230 | }) 231 | ).toThrowError( 232 | `Property "requiredMacroOptions" has invalid type of values. It can only contain strings.` 233 | ) 234 | }) 235 | }) 236 | -------------------------------------------------------------------------------- /src/utils/parseMacros.spec.ts: -------------------------------------------------------------------------------- 1 | import { LintConfig } from '../types' 2 | import { parseMacros } from './parseMacros' 3 | 4 | describe('parseMacros', () => { 5 | it('should return an array with a single macro', () => { 6 | const text = ` %macro test;\n %put 'hello';\n%mend` 7 | 8 | const macros = parseMacros(text, new LintConfig()) 9 | 10 | expect(macros.length).toEqual(1) 11 | expect(macros).toContainEqual({ 12 | name: 'test', 13 | declarationLines: [' %macro test;'], 14 | terminationLine: '%mend', 15 | declaration: '%macro test', 16 | termination: '%mend', 17 | startLineNumbers: [1], 18 | endLineNumber: 3, 19 | parentMacro: '', 20 | hasMacroNameInMend: false, 21 | mismatchedMendMacroName: '' 22 | }) 23 | }) 24 | 25 | it('should return an array with a single macro having parameters', () => { 26 | const text = `%macro test(var,sum);\n %put 'hello';\n%mend` 27 | 28 | const macros = parseMacros(text, new LintConfig()) 29 | 30 | expect(macros.length).toEqual(1) 31 | expect(macros).toContainEqual({ 32 | name: 'test', 33 | declarationLines: ['%macro test(var,sum);'], 34 | terminationLine: '%mend', 35 | declaration: '%macro test(var,sum)', 36 | termination: '%mend', 37 | startLineNumbers: [1], 38 | endLineNumber: 3, 39 | parentMacro: '', 40 | hasMacroNameInMend: false, 41 | mismatchedMendMacroName: '' 42 | }) 43 | }) 44 | 45 | it('should return an array with a single macro having PARMBUFF option', () => { 46 | const text = `%macro test/parmbuff;\n %put 'hello';\n%mend` 47 | 48 | const macros = parseMacros(text, new LintConfig()) 49 | 50 | expect(macros.length).toEqual(1) 51 | expect(macros).toContainEqual({ 52 | name: 'test', 53 | declarationLines: ['%macro test/parmbuff;'], 54 | terminationLine: '%mend', 55 | declaration: '%macro test/parmbuff', 56 | termination: '%mend', 57 | startLineNumbers: [1], 58 | endLineNumber: 3, 59 | parentMacro: '', 60 | hasMacroNameInMend: false, 61 | mismatchedMendMacroName: '' 62 | }) 63 | }) 64 | 65 | it('should return an array with a single macro having paramerter & SOURCE option', () => { 66 | const text = `/* commentary */ %macro foobar(arg) /store source\n des="This macro does not do much";\n %put 'hello';\n%mend` 67 | 68 | const macros = parseMacros(text, new LintConfig()) 69 | 70 | expect(macros.length).toEqual(1) 71 | expect(macros).toContainEqual({ 72 | name: 'foobar', 73 | declarationLines: [ 74 | '/* commentary */ %macro foobar(arg) /store source', 75 | ' des="This macro does not do much";' 76 | ], 77 | terminationLine: '%mend', 78 | declaration: 79 | '%macro foobar(arg) /store source des="This macro does not do much"', 80 | termination: '%mend', 81 | startLineNumbers: [1, 2], 82 | endLineNumber: 4, 83 | parentMacro: '', 84 | hasMacroNameInMend: false, 85 | mismatchedMendMacroName: '' 86 | }) 87 | }) 88 | 89 | it('should return an array with multiple macros', () => { 90 | const text = `%macro foo;\n %put 'foo';\n%mend;\n%macro bar();\n %put 'bar';\n%mend bar;` 91 | 92 | const macros = parseMacros(text, new LintConfig()) 93 | 94 | expect(macros.length).toEqual(2) 95 | expect(macros).toContainEqual({ 96 | name: 'foo', 97 | declarationLines: ['%macro foo;'], 98 | terminationLine: '%mend;', 99 | declaration: '%macro foo', 100 | termination: '%mend', 101 | startLineNumbers: [1], 102 | endLineNumber: 3, 103 | parentMacro: '', 104 | hasMacroNameInMend: false, 105 | mismatchedMendMacroName: '' 106 | }) 107 | expect(macros).toContainEqual({ 108 | name: 'bar', 109 | declarationLines: ['%macro bar();'], 110 | terminationLine: '%mend bar;', 111 | declaration: '%macro bar()', 112 | termination: '%mend bar', 113 | startLineNumbers: [4], 114 | endLineNumber: 6, 115 | parentMacro: '', 116 | hasMacroNameInMend: true, 117 | mismatchedMendMacroName: '' 118 | }) 119 | }) 120 | 121 | it('should detect nested macro definitions', () => { 122 | const text = `%macro test();\n %put 'hello';\n %macro test2;\n %put 'world;\n %mend\n%mend test` 123 | 124 | const macros = parseMacros(text, new LintConfig()) 125 | 126 | expect(macros.length).toEqual(2) 127 | expect(macros).toContainEqual({ 128 | name: 'test', 129 | declarationLines: ['%macro test();'], 130 | terminationLine: '%mend test', 131 | declaration: '%macro test()', 132 | termination: '%mend test', 133 | startLineNumbers: [1], 134 | endLineNumber: 6, 135 | parentMacro: '', 136 | hasMacroNameInMend: true, 137 | mismatchedMendMacroName: '' 138 | }) 139 | expect(macros).toContainEqual({ 140 | name: 'test2', 141 | declarationLines: [' %macro test2;'], 142 | terminationLine: ' %mend', 143 | declaration: '%macro test2', 144 | termination: '%mend', 145 | startLineNumbers: [3], 146 | endLineNumber: 5, 147 | parentMacro: 'test', 148 | hasMacroNameInMend: false, 149 | mismatchedMendMacroName: '' 150 | }) 151 | }) 152 | 153 | describe(`multi-line macro declarations`, () => { 154 | it('should return an array with a single macro', () => { 155 | const text = `%macro \n test;\n %put 'hello';\n%mend` 156 | 157 | const macros = parseMacros(text, new LintConfig()) 158 | 159 | expect(macros.length).toEqual(1) 160 | expect(macros).toContainEqual({ 161 | name: 'test', 162 | declarationLines: ['%macro ', ' test;'], 163 | terminationLine: '%mend', 164 | declaration: '%macro test', 165 | termination: '%mend', 166 | startLineNumbers: [1, 2], 167 | endLineNumber: 4, 168 | parentMacro: '', 169 | hasMacroNameInMend: false, 170 | mismatchedMendMacroName: '' 171 | }) 172 | }) 173 | 174 | it('should return an array with a single macro having parameters', () => { 175 | const text = `%macro \n test(\n var,\n sum);%put 'hello';\n%mend` 176 | 177 | const macros = parseMacros(text, new LintConfig()) 178 | 179 | expect(macros.length).toEqual(1) 180 | expect(macros).toContainEqual({ 181 | name: 'test', 182 | declarationLines: [ 183 | '%macro ', 184 | ` test(`, 185 | ` var,`, 186 | ` sum);%put 'hello';` 187 | ], 188 | terminationLine: '%mend', 189 | declaration: '%macro test( var, sum)', 190 | termination: '%mend', 191 | startLineNumbers: [1, 2, 3, 4], 192 | endLineNumber: 5, 193 | parentMacro: '', 194 | hasMacroNameInMend: false, 195 | mismatchedMendMacroName: '' 196 | }) 197 | }) 198 | 199 | it('should return an array with a single macro having PARMBUFF option', () => { 200 | const text = `%macro test\n /parmbuff;\n %put 'hello';\n%mend` 201 | 202 | const macros = parseMacros(text, new LintConfig()) 203 | 204 | expect(macros.length).toEqual(1) 205 | expect(macros).toContainEqual({ 206 | name: 'test', 207 | declarationLines: ['%macro test', ' /parmbuff;'], 208 | terminationLine: '%mend', 209 | declaration: '%macro test /parmbuff', 210 | termination: '%mend', 211 | startLineNumbers: [1, 2], 212 | endLineNumber: 4, 213 | parentMacro: '', 214 | hasMacroNameInMend: false, 215 | mismatchedMendMacroName: '' 216 | }) 217 | }) 218 | 219 | it('should return an array with a single macro having paramerter & SOURCE option', () => { 220 | const text = `/* commentary */ %macro foobar/* commentary */(arg) \n /* commentary */\n /store\n /* commentary */source\n des="This macro does not do much";\n %put 'hello';\n%mend` 221 | 222 | const macros = parseMacros(text, new LintConfig()) 223 | 224 | expect(macros.length).toEqual(1) 225 | expect(macros).toContainEqual({ 226 | name: 'foobar', 227 | declarationLines: [ 228 | '/* commentary */ %macro foobar/* commentary */(arg) ', 229 | ' /* commentary */', 230 | ' /store', 231 | ' /* commentary */source', 232 | ' des="This macro does not do much";' 233 | ], 234 | terminationLine: '%mend', 235 | declaration: 236 | '%macro foobar(arg) /store source des="This macro does not do much"', 237 | termination: '%mend', 238 | startLineNumbers: [1, 2, 3, 4, 5], 239 | endLineNumber: 7, 240 | parentMacro: '', 241 | hasMacroNameInMend: false, 242 | mismatchedMendMacroName: '' 243 | }) 244 | }) 245 | 246 | it('should return an array with a single macro having semi-colon in params', () => { 247 | const text = `\n%macro mm_createapplication(\n tree=/User Folders/sasdemo\n ,name=myApp\n ,ClassIdentifier=mcore\n ,desc=Created by mm_createapplication\n ,params= param1=1 param2=blah\n ,version=\n ,frefin=mm_in\n ,frefout=mm_out\n ,mDebug=1\n );` 248 | 249 | const macros = parseMacros(text, new LintConfig()) 250 | 251 | expect(macros.length).toEqual(1) 252 | expect(macros).toContainEqual({ 253 | name: 'mm_createapplication', 254 | declarationLines: [ 255 | `%macro mm_createapplication(`, 256 | ` tree=/User Folders/sasdemo`, 257 | ` ,name=myApp`, 258 | ` ,ClassIdentifier=mcore`, 259 | ` ,desc=Created by mm_createapplication`, 260 | ` ,params= param1=1 param2=blah`, 261 | ` ,version=`, 262 | ` ,frefin=mm_in`, 263 | ` ,frefout=mm_out`, 264 | ` ,mDebug=1`, 265 | ` );` 266 | ], 267 | terminationLine: '', 268 | declaration: 269 | '%macro mm_createapplication( tree=/User Folders/sasdemo ,name=myApp ,ClassIdentifier=mcore ,desc=Created by mm_createapplication ,params= param1=1 param2=blah ,version= ,frefin=mm_in ,frefout=mm_out ,mDebug=1 )', 270 | termination: '', 271 | startLineNumbers: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], 272 | endLineNumber: null, 273 | parentMacro: '', 274 | hasMacroNameInMend: false, 275 | mismatchedMendMacroName: '' 276 | }) 277 | }) 278 | }) 279 | }) 280 | --------------------------------------------------------------------------------