1
0
mirror of https://github.com/sasjs/lint.git synced 2025-12-10 17:34:36 +00:00

Compare commits

..

16 Commits

Author SHA1 Message Date
Krishna Acondy
7aa4bfc6ba Merge pull request #13 from sasjs/lint-folder-project
feat(lint): add lintFolder and lintProject APIs
2021-03-31 08:57:34 +01:00
Krishna Acondy
ffcd57d5f7 Merge branch 'main' into lint-folder-project 2021-03-31 08:36:16 +01:00
Krishna Acondy
86a6d36693 chore(*): fix exports 2021-03-31 08:36:04 +01:00
Krishna Acondy
28d5e7121a chore(*): throw error when project root is not found 2021-03-31 08:34:02 +01:00
Krishna Acondy
c0d27fa254 chore(*): split lint module into smaller submodules, added tests 2021-03-31 08:32:42 +01:00
Allan Bowe
12bfcd69bd Merge pull request #15 from sasjs/doxygen-header-false-negative
fix(has-doxygen-header): fix logic to handle files with comment blocks
2021-03-30 09:29:23 +01:00
Krishna Acondy
6350d32d0c fix(has-doxygen-header): fix logic to handle files with comment blocks 2021-03-30 09:24:03 +01:00
Krishna Acondy
a8ca534b0b feat(lint): add lintFolder and lintProject APIs 2021-03-30 08:59:38 +01:00
Krishna Acondy
1c09a10290 fix(*): remove warning when using default config 2021-03-30 08:42:52 +01:00
Krishna Acondy
7a2e693123 Merge pull request #10 from sasjs/add-lint-rules 2021-03-29 10:57:01 +01:00
Krishna Acondy
2ad42634d7 chore(*): update schema 2021-03-29 09:42:44 +01:00
Krishna Acondy
52b63bac58 fix(lint): ignore indentation multiple when set to zero 2021-03-29 09:40:32 +01:00
Krishna Acondy
f1adcb8cb4 feat(lint): add rule for indentation multiple 2021-03-29 09:26:20 +01:00
Krishna Acondy
8fc3c39993 chore(*): export utils modules and default config 2021-03-26 09:13:07 +00:00
Krishna Acondy
3631f5c25c feat(lint): add rules for lowercase file names, max line length and no tab indentation 2021-03-26 09:09:42 +00:00
Krishna Acondy
1be358ca51 fix(schema): fix boolean field values in .sasjslint schema 2021-03-24 20:05:20 +00:00
42 changed files with 995 additions and 291 deletions

View File

@@ -2,5 +2,9 @@
"noTrailingSpaces": true,
"noEncodedPasswords": true,
"hasDoxygenHeader": true,
"noSpacesInFileNames": true
"noSpacesInFileNames": true,
"maxLineLength": 80,
"lowerCaseFileNames": true,
"noTabIndentation": true,
"indentationMultiple": 2
}

12
package-lock.json generated
View File

@@ -648,9 +648,9 @@
}
},
"@sasjs/utils": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-2.9.0.tgz",
"integrity": "sha512-j7ssEmb8OSZHUUL0PGVgoby0j0ClCcsLsydDCk/C4OAoWPAUPFI5HgGFPSEipz9+P8OlL/EBnglj4LGtlFHCpw==",
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-2.10.1.tgz",
"integrity": "sha512-T54jx6NEMLu2+R/ux4qcb3dDJ7nFrKkPCkmPXEfZxPQBkbq4C0kmaZv6dC63RDH68wYhoXR2S5fION5fFh91iw==",
"requires": {
"@types/prompts": "^2.0.9",
"consola": "^2.15.0",
@@ -778,9 +778,9 @@
"dev": true
},
"@types/prompts": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.0.9.tgz",
"integrity": "sha512-TORZP+FSjTYMWwKadftmqEn6bziN5RnfygehByGsjxoK5ydnClddtv6GikGWPvCm24oI+YBwck5WDxIIyNxUrA==",
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.0.10.tgz",
"integrity": "sha512-W3PEl3l4vmxdgfY6LUG7ysh+mLJOTOFYmSpiLe6MCo1OdEm8b5s6ZJfuTQgEpYNwcMiiaRzJespPS5Py2tqLlQ==",
"requires": {
"@types/node": "*"
}

View File

@@ -44,6 +44,6 @@
"typescript": "^4.2.3"
},
"dependencies": {
"@sasjs/utils": "^2.9.0"
"@sasjs/utils": "^2.10.1"
}
}

View File

@@ -8,14 +8,22 @@
"noTrailingSpaces": true,
"noEncodedPasswords": true,
"hasDoxygenHeader": true,
"noSpacesInFileNames": true
"noSpacesInFileNames": true,
"lowerCaseFileNames": true,
"maxLineLength": 80,
"noTabIndentation": true,
"indentationMultiple": 2
},
"examples": [
{
"noTrailingSpaces": true,
"noEncodedPasswords": true,
"hasDoxygenHeader": true,
"noSpacesInFileNames": true
"noSpacesInFileNames": true,
"lowerCaseFileNames": true,
"maxLineLength": 80,
"noTabIndentation": true,
"indentationMultiple": 4
}
],
"properties": {
@@ -24,32 +32,64 @@
"type": "boolean",
"title": "noTrailingSpaces",
"description": "Enforces no trailing spaces in lines of SAS code. Shows a warning when they are present.",
"default": "true",
"examples": ["true", "false"]
"default": true,
"examples": [true, false]
},
"noEncodedPasswords": {
"$id": "#/properties/noEncodedPasswords",
"type": "boolean",
"title": "noEncodedPasswords",
"description": "Enforces no encoded passwords such as {SAS001} or {SASENC} in lines of SAS code. Shows an error when they are present.",
"default": "true",
"examples": ["true", "false"]
"default": true,
"examples": [true, false]
},
"hasDoxygenHeader": {
"$id": "#/properties/hasDoxygenHeader",
"type": "boolean",
"title": "hasDoxygenHeader",
"description": "Enforces the presence of a Doxygen header in the form of a comment block at the start of each SAS file. Shows a warning when one is absent.",
"default": "true",
"examples": ["true", "false"]
"default": true,
"examples": [true, false]
},
"noSpacesInFileNames": {
"$id": "#/properties/noSpacesInFileNames",
"type": "boolean",
"title": "noSpacesInFileNames",
"description": "Enforces no spaces in file names. Shows a warning when they are present.",
"default": "true",
"examples": ["true", "false"]
"default": true,
"examples": [true, false]
},
"lowerCaseFileNames": {
"$id": "#/properties/lowerCaseFileNames",
"type": "boolean",
"title": "lowerCaseFileNames",
"description": "Enforces no uppercase characters in file names. Shows a warning when they are present.",
"default": true,
"examples": [true, false]
},
"maxLineLength": {
"$id": "#/properties/maxLineLength",
"type": "number",
"title": "maxLineLength",
"description": "Enforces a configurable maximum line length. Shows a warning for lines exceeding this length.",
"default": 80,
"examples": [60, 80, 120]
},
"noTabIndentation": {
"$id": "#/properties/noTabIndentation",
"type": "boolean",
"title": "noTabIndentation",
"description": "Enforces no indentation using tabs. Shows a warning when a line starts with a tab.",
"default": true,
"examples": [true, false]
},
"indentationMultiple": {
"$id": "#/properties/indentationMultiple",
"type": "number",
"title": "indentationMultiple",
"description": "Enforces a configurable multiple for the number of spaces for indentation. Shows a warning for lines that are not indented by a multiple of this number.",
"default": 2,
"examples": [2, 3, 4]
}
}
}

18
src/Example File.sas Normal file
View File

@@ -0,0 +1,18 @@
%macro mf_getuniquelibref(prefix=mclib,maxtries=1000);
%local x libref;
%let x={SAS002};
%do x=0 %to &maxtries;
%if %sysfunc(libref(&prefix&x)) ne 0 %then %do;
%let libref=&prefix&x;
%let rc=%sysfunc(libname(&libref,%sysfunc(pathname(work))));
%if &rc %then %put %sysfunc(sysmsg());
&prefix&x
%*put &sysmacroname: Libref &libref assigned as WORK and returned;
%return;
%end;
%end;
%put unable to find available libref in range &prefix.0-&maxtries;
%mend;

View File

@@ -1,17 +0,0 @@
%macro mf_getuniquelibref(prefix=mclib,maxtries=1000);
%local x libref;
%let x={SAS002};
%do x=0 %to &maxtries;
%if %sysfunc(libref(&prefix&x)) ne 0 %then %do;
%let libref=&prefix&x;
%let rc=%sysfunc(libname(&libref,%sysfunc(pathname(work))));
%if &rc %then %put %sysfunc(sysmsg());
&prefix&x
%*put &sysmacroname: Libref &libref assigned as WORK and returned;
%return;
%end;
%end;
%put unable to find available libref in range &prefix.0-&maxtries;
%mend;

View File

@@ -1,49 +1,58 @@
import { lintText } from './lint'
import { lintFile, lintText } from './lint'
import path from 'path'
/**
* Example which tests a piece of text with all known violations.
*/
const text = `/**
@file
@brief Returns an unused libref
@details Use as follows:
@file
@brief Returns an unused libref
@details Use as follows:
libname mclib0 (work);
libname mclib1 (work);
libname mclib2 (work);
libname mclib0 (work);
libname mclib1 (work);
libname mclib2 (work);
%let libref=%mf_getuniquelibref({SAS001});
%put &=libref;
%let libref=%mf_getuniquelibref({SAS001});
%put &=libref;
which returns:
which returns:
> mclib3
@param prefix= first part of libref. Remember that librefs can only be 8 characters,
so a 7 letter prefix would mean that maxtries should be 10.
@param maxtries= the last part of the libref. Provide an integer value.
@param prefix= first part of libref. Remember that librefs can only be 8 characters,
so a 7 letter prefix would mean that maxtries should be 10.
@param maxtries= the last part of the libref. Provide an integer value.
@version 9.2
@author Allan Bowe
@version 9.2
@author Allan Bowe
**/
%macro mf_getuniquelibref(prefix=mclib,maxtries=1000);
%local x libref;
%let x={SAS002};
%macro mf_getuniquelibref(prefix=mclib,maxtries=1000);
%local x libref;
%let x={SAS002};
%do x=0 %to &maxtries;
%if %sysfunc(libref(&prefix&x)) ne 0 %then %do;
%let libref=&prefix&x;
%let rc=%sysfunc(libname(&libref,%sysfunc(pathname(work))));
%if &rc %then %put %sysfunc(sysmsg());
&prefix&x
%*put &sysmacroname: Libref &libref assigned as WORK and returned;
%return;
%end;
%end;
%put unable to find available libref in range &prefix.0-&maxtries;
%mend;
%if %sysfunc(libref(&prefix&x)) ne 0 %then %do;
%let libref=&prefix&x;
%let rc=%sysfunc(libname(&libref,%sysfunc(pathname(work))));
%if &rc %then %put %sysfunc(sysmsg());
&prefix&x
%*put &sysmacroname: Libref &libref assigned as WORK and returned;
%return;
%end;
%end;
%put unable to find available libref in range &prefix.0-&maxtries;
%mend;
`
lintText(text).then((diagnostics) => console.table(diagnostics))
lintText(text).then((diagnostics) => {
console.log('Text lint results:')
console.table(diagnostics)
})
lintFile(path.join(__dirname, 'Example File.sas')).then((diagnostics) => {
console.log('File lint results:')
console.table(diagnostics)
})

View File

@@ -1,2 +1,3 @@
export { lintText, lintFile } from './lint'
export * from './lint'
export * from './types'
export * from './utils'

View File

@@ -1,137 +0,0 @@
import { lintFile, lintText, splitText } from './lint'
import { Severity } from './types/Severity'
import path from 'path'
describe('lintText', () => {
it('should identify trailing spaces', async () => {
const text = `/**
@file
**/
%put 'hello';
%put 'world'; `
const results = await lintText(text)
expect(results.length).toEqual(2)
expect(results[0]).toEqual({
message: 'Line contains trailing spaces',
lineNumber: 4,
startColumnNumber: 18,
endColumnNumber: 18,
severity: Severity.Warning
})
expect(results[1]).toEqual({
message: 'Line contains trailing spaces',
lineNumber: 5,
startColumnNumber: 22,
endColumnNumber: 23,
severity: Severity.Warning
})
})
it('should identify encoded passwords', async () => {
const text = `/**
@file
**/
%put '{SAS001}';`
const results = await lintText(text)
expect(results.length).toEqual(1)
expect(results[0]).toEqual({
message: 'Line contains encoded password',
lineNumber: 4,
startColumnNumber: 11,
endColumnNumber: 19,
severity: Severity.Error
})
})
it('should identify missing doxygen header', async () => {
const text = `%put 'hello';`
const results = await lintText(text)
expect(results.length).toEqual(1)
expect(results[0]).toEqual({
message: 'File missing Doxygen header',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
})
it('should return an empty list with an empty file', async () => {
const text = `/**
@file
**/`
const results = await lintText(text)
expect(results.length).toEqual(0)
})
})
describe('lintFile', () => {
it('should identify lint issues in a given file', async () => {
const results = await lintFile(path.join(__dirname, 'example file.sas'))
expect(results.length).toEqual(5)
expect(results).toContainEqual({
message: 'Line contains trailing spaces',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 2,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'Line contains trailing spaces',
lineNumber: 2,
startColumnNumber: 1,
endColumnNumber: 2,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'File name contains spaces',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'File missing Doxygen header',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'Line contains encoded password',
lineNumber: 5,
startColumnNumber: 11,
endColumnNumber: 19,
severity: Severity.Error
})
})
})
describe('splitText', () => {
it('should return an empty array when text is falsy', () => {
const lines = splitText('')
expect(lines.length).toEqual(0)
})
it('should return an array of lines from text', () => {
const lines = splitText(`line 1\nline 2`)
expect(lines.length).toEqual(2)
expect(lines[0]).toEqual('line 1')
expect(lines[1]).toEqual('line 2')
})
it('should work with CRLF line endings', () => {
const lines = splitText(`line 1\r\nline 2`)
expect(lines.length).toEqual(2)
expect(lines[0]).toEqual('line 1')
expect(lines[1]).toEqual('line 2')
})
})

View File

@@ -1,81 +0,0 @@
import { readFile } from '@sasjs/utils/file'
import { Diagnostic } from './types/Diagnostic'
import { LintConfig } from './types/LintConfig'
import { getLintConfig } from './utils/getLintConfig'
/**
* Analyses and produces a set of diagnostics for the given text content.
* @param {string} text - the text content to be linted.
* @returns {Diagnostic[]} array of diagnostic objects, each containing a warning, line number and column number.
*/
export const lintText = async (text: string) => {
const config = await getLintConfig()
return processText(text, config)
}
/**
* Analyses and produces a set of diagnostics for the file at the given path.
* @param {string} filePath - the path to the file to be linted.
* @returns {Diagnostic[]} array of diagnostic objects, each containing a warning, line number and column number.
*/
export const lintFile = async (filePath: string) => {
const config = await getLintConfig()
const text = await readFile(filePath)
const fileDiagnostics = processFile(filePath, config)
const textDiagnostics = processText(text, config)
return [...fileDiagnostics, ...textDiagnostics]
}
/**
* Splits the given content into a list of lines, regardless of CRLF or LF line endings.
* @param {string} text - the text content to be split into lines.
* @returns {string[]} an array of lines from the given text
*/
export const splitText = (text: string): string[] => {
if (!text) return []
return text.replace(/\r\n/g, '\n').split('\n')
}
const processText = (text: string, config: LintConfig) => {
const lines = splitText(text)
const diagnostics: Diagnostic[] = []
diagnostics.push(...processContent(config, text))
lines.forEach((line, index) => {
diagnostics.push(...processLine(config, line, index + 1))
})
return diagnostics
}
const processContent = (config: LintConfig, content: string): Diagnostic[] => {
const diagnostics: Diagnostic[] = []
config.fileLintRules.forEach((rule) => {
diagnostics.push(...rule.test(content))
})
return diagnostics
}
const processLine = (
config: LintConfig,
line: string,
lineNumber: number
): Diagnostic[] => {
const diagnostics: Diagnostic[] = []
config.lineLintRules.forEach((rule) => {
diagnostics.push(...rule.test(line, lineNumber))
})
return diagnostics
}
const processFile = (filePath: string, config: LintConfig): Diagnostic[] => {
const diagnostics: Diagnostic[] = []
config.pathLintRules.forEach((rule) => {
diagnostics.push(...rule.test(filePath))
})
return diagnostics
}

4
src/lint/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './lintText'
export * from './lintFile'
export * from './lintFolder'
export * from './lintProject'

69
src/lint/lintFile.spec.ts Normal file
View File

@@ -0,0 +1,69 @@
import { lintFile } from './lintFile'
import { Severity } from '../types/Severity'
import path from 'path'
describe('lintFile', () => {
it('should identify lint issues in a given file', async () => {
const results = await lintFile(
path.join(__dirname, '..', 'Example File.sas')
)
expect(results.length).toEqual(8)
expect(results).toContainEqual({
message: 'Line contains trailing spaces',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 2,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'Line contains trailing spaces',
lineNumber: 2,
startColumnNumber: 1,
endColumnNumber: 2,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'File name contains spaces',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'File name contains uppercase characters',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'File missing Doxygen header',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'Line contains encoded password',
lineNumber: 5,
startColumnNumber: 10,
endColumnNumber: 18,
severity: Severity.Error
})
expect(results).toContainEqual({
message: 'Line is indented with a tab',
lineNumber: 7,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'Line has incorrect indentation - 3 spaces',
lineNumber: 6,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
})
})

23
src/lint/lintFile.ts Normal file
View File

@@ -0,0 +1,23 @@
import { readFile } from '@sasjs/utils/file'
import { LintConfig } from '../types/LintConfig'
import { getLintConfig } from '../utils/getLintConfig'
import { processFile, processText } from './shared'
/**
* Analyses and produces a set of diagnostics for the file at the given path.
* @param {string} filePath - the path to the file to be linted.
* @param {LintConfig} configuration - an optional configuration. When not passed in, this is read from the .sasjslint file.
* @returns {Diagnostic[]} array of diagnostic objects, each containing a warning, line number and column number.
*/
export const lintFile = async (
filePath: string,
configuration?: LintConfig
) => {
const config = configuration || (await getLintConfig())
const text = await readFile(filePath)
const fileDiagnostics = processFile(filePath, config)
const textDiagnostics = processText(text, config)
return [...fileDiagnostics, ...textDiagnostics]
}

View File

@@ -0,0 +1,67 @@
import { lintFolder } from './lintFolder'
import { Severity } from '../types/Severity'
import path from 'path'
describe('lintFolder', () => {
it('should identify lint issues in a given folder', async () => {
const results = await lintFolder(path.join(__dirname, '..'))
expect(results.length).toEqual(8)
expect(results).toContainEqual({
message: 'Line contains trailing spaces',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 2,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'Line contains trailing spaces',
lineNumber: 2,
startColumnNumber: 1,
endColumnNumber: 2,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'File name contains spaces',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'File name contains uppercase characters',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'File missing Doxygen header',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'Line contains encoded password',
lineNumber: 5,
startColumnNumber: 10,
endColumnNumber: 18,
severity: Severity.Error
})
expect(results).toContainEqual({
message: 'Line is indented with a tab',
lineNumber: 7,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'Line has incorrect indentation - 3 spaces',
lineNumber: 6,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
})
})

49
src/lint/lintFolder.ts Normal file
View File

@@ -0,0 +1,49 @@
import { listSubFoldersInFolder } from '@sasjs/utils/file'
import path from 'path'
import { Diagnostic } from '../types/Diagnostic'
import { LintConfig } from '../types/LintConfig'
import { asyncForEach } from '../utils/asyncForEach'
import { getLintConfig } from '../utils/getLintConfig'
import { listSasFiles } from '../utils/listSasFiles'
import { lintFile } from './lintFile'
const excludeFolders = [
'.git',
'.github',
'.vscode',
'node_modules',
'sasjsbuild',
'sasjsresults'
]
/**
* Analyses and produces a set of diagnostics for the folder at the given path.
* @param {string} folderPath - the path to the folder to be linted.
* @param {LintConfig} configuration - an optional configuration. When not passed in, this is read from the .sasjslint file.
* @returns {Diagnostic[]} array of diagnostic objects, each containing a warning, line number and column number.
*/
export const lintFolder = async (
folderPath: string,
configuration?: LintConfig
) => {
const config = configuration || (await getLintConfig())
const diagnostics: Diagnostic[] = []
const fileNames = await listSasFiles(folderPath)
await asyncForEach(fileNames, async (fileName) => {
diagnostics.push(
...(await lintFile(path.join(folderPath, fileName), config))
)
})
const subFolders = (await listSubFoldersInFolder(folderPath)).filter(
(f: string) => !excludeFolders.includes(f)
)
await asyncForEach(subFolders, async (subFolder) => {
diagnostics.push(
...(await lintFolder(path.join(folderPath, subFolder), config))
)
})
return diagnostics
}

View File

@@ -0,0 +1,67 @@
import { lintProject } from './lintProject'
import { Severity } from '../types/Severity'
import path from 'path'
describe('lintProject', () => {
it('should identify lint issues in a given project', async () => {
const results = await lintProject()
expect(results.length).toEqual(8)
expect(results).toContainEqual({
message: 'Line contains trailing spaces',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 2,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'Line contains trailing spaces',
lineNumber: 2,
startColumnNumber: 1,
endColumnNumber: 2,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'File name contains spaces',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'File name contains uppercase characters',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'File missing Doxygen header',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'Line contains encoded password',
lineNumber: 5,
startColumnNumber: 10,
endColumnNumber: 18,
severity: Severity.Error
})
expect(results).toContainEqual({
message: 'Line is indented with a tab',
lineNumber: 7,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(results).toContainEqual({
message: 'Line has incorrect indentation - 3 spaces',
lineNumber: 6,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
})
})

16
src/lint/lintProject.ts Normal file
View File

@@ -0,0 +1,16 @@
import { getProjectRoot } from '../utils'
import { lintFolder } from './lintFolder'
/**
* Analyses and produces a set of diagnostics for the current project.
* @returns {Diagnostic[]} array of diagnostic objects, each containing a warning, line number and column number.
*/
export const lintProject = async () => {
const projectRoot =
(await getProjectRoot()) || process.projectDir || process.currentDir
if (!projectRoot) {
throw new Error('SASjs Project Root was not found.')
}
return await lintFolder(projectRoot)
}

69
src/lint/lintText.spec.ts Normal file
View File

@@ -0,0 +1,69 @@
import { lintText } from './lintText'
import { Severity } from '../types/Severity'
describe('lintText', () => {
it('should identify trailing spaces', async () => {
const text = `/**
@file
**/
%put 'hello';
%put 'world'; `
const results = await lintText(text)
expect(results.length).toEqual(2)
expect(results[0]).toEqual({
message: 'Line contains trailing spaces',
lineNumber: 4,
startColumnNumber: 18,
endColumnNumber: 18,
severity: Severity.Warning
})
expect(results[1]).toEqual({
message: 'Line contains trailing spaces',
lineNumber: 5,
startColumnNumber: 22,
endColumnNumber: 23,
severity: Severity.Warning
})
})
it('should identify encoded passwords', async () => {
const text = `/**
@file
**/
%put '{SAS001}';`
const results = await lintText(text)
expect(results.length).toEqual(1)
expect(results[0]).toEqual({
message: 'Line contains encoded password',
lineNumber: 4,
startColumnNumber: 11,
endColumnNumber: 19,
severity: Severity.Error
})
})
it('should identify missing doxygen header', async () => {
const text = `%put 'hello';`
const results = await lintText(text)
expect(results.length).toEqual(1)
expect(results[0]).toEqual({
message: 'File missing Doxygen header',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
})
it('should return an empty list with an empty file', async () => {
const text = `/**
@file
**/`
const results = await lintText(text)
expect(results.length).toEqual(0)
})
})

12
src/lint/lintText.ts Normal file
View File

@@ -0,0 +1,12 @@
import { getLintConfig } from '../utils/getLintConfig'
import { processText } from './shared'
/**
* Analyses and produces a set of diagnostics for the given text content.
* @param {string} text - the text content to be linted.
* @returns {Diagnostic[]} array of diagnostic objects, each containing a warning, line number and column number.
*/
export const lintText = async (text: string) => {
const config = await getLintConfig()
return processText(text, config)
}

25
src/lint/shared.spec.ts Normal file
View File

@@ -0,0 +1,25 @@
import { splitText } from './shared'
describe('splitText', () => {
it('should return an empty array when text is falsy', () => {
const lines = splitText('')
expect(lines.length).toEqual(0)
})
it('should return an array of lines from text', () => {
const lines = splitText(`line 1\nline 2`)
expect(lines.length).toEqual(2)
expect(lines[0]).toEqual('line 1')
expect(lines[1]).toEqual('line 2')
})
it('should work with CRLF line endings', () => {
const lines = splitText(`line 1\r\nline 2`)
expect(lines.length).toEqual(2)
expect(lines[0]).toEqual('line 1')
expect(lines[1]).toEqual('line 2')
})
})

56
src/lint/shared.ts Normal file
View File

@@ -0,0 +1,56 @@
import { LintConfig, Diagnostic } from '../types'
/**
* Splits the given content into a list of lines, regardless of CRLF or LF line endings.
* @param {string} text - the text content to be split into lines.
* @returns {string[]} an array of lines from the given text
*/
export const splitText = (text: string): string[] => {
if (!text) return []
return text.replace(/\r\n/g, '\n').split('\n')
}
export const processText = (text: string, config: LintConfig) => {
const lines = splitText(text)
const diagnostics: Diagnostic[] = []
diagnostics.push(...processContent(config, text))
lines.forEach((line, index) => {
diagnostics.push(...processLine(config, line, index + 1))
})
return diagnostics
}
export const processFile = (
filePath: string,
config: LintConfig
): Diagnostic[] => {
const diagnostics: Diagnostic[] = []
config.pathLintRules.forEach((rule) => {
diagnostics.push(...rule.test(filePath))
})
return diagnostics
}
const processContent = (config: LintConfig, content: string): Diagnostic[] => {
const diagnostics: Diagnostic[] = []
config.fileLintRules.forEach((rule) => {
diagnostics.push(...rule.test(content))
})
return diagnostics
}
export const processLine = (
config: LintConfig,
line: string,
lineNumber: number
): Diagnostic[] => {
const diagnostics: Diagnostic[] = []
config.lineLintRules.forEach((rule) => {
diagnostics.push(...rule.test(line, lineNumber, config))
})
return diagnostics
}

View File

@@ -34,6 +34,27 @@ describe('hasDoxygenHeader', () => {
])
})
it('should return an array with a single diagnostic when the file has comment blocks but no header', () => {
const content = `
%macro mf_getuniquelibref(prefix=mclib,maxtries=1000);
%local x libref;
%let x={SAS002};
/** Comment Line 1
* Comment Line 2
*/
%do x=0 %to &maxtries;`
expect(hasDoxygenHeader.test(content)).toEqual([
{
message: 'File missing Doxygen header',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
}
])
})
it('should return an array with a single diagnostic when the file is undefined', () => {
const content = undefined

View File

@@ -8,7 +8,7 @@ const description =
const message = 'File missing Doxygen header'
const test = (value: string) => {
try {
const hasFileHeader = value.split('/**')[0] !== value
const hasFileHeader = value.trimStart().startsWith('/*')
if (hasFileHeader) return []
return [
{

View File

@@ -0,0 +1,74 @@
import { LintConfig, Severity } from '../types'
import { indentationMultiple } from './indentationMultiple'
describe('indentationMultiple', () => {
it('should return an empty array when the line is indented by two spaces', () => {
const line = " %put 'hello';"
const config = new LintConfig({ indentationMultiple: 2 })
expect(indentationMultiple.test(line, 1, config)).toEqual([])
})
it('should return an empty array when the line is indented by a multiple of 2 spaces', () => {
const line = " %put 'hello';"
const config = new LintConfig({ indentationMultiple: 2 })
expect(indentationMultiple.test(line, 1, config)).toEqual([])
})
it('should ignore indentation when the multiple is set to 0', () => {
const line = " %put 'hello';"
const config = new LintConfig({ indentationMultiple: 0 })
expect(indentationMultiple.test(line, 1, config)).toEqual([])
})
it('should return an empty array when the line is not indented', () => {
const line = "%put 'hello';"
const config = new LintConfig({ indentationMultiple: 2 })
expect(indentationMultiple.test(line, 1, config)).toEqual([])
})
it('should return an array with a single diagnostic when the line is indented incorrectly', () => {
const line = " %put 'hello';"
const config = new LintConfig({ indentationMultiple: 2 })
expect(indentationMultiple.test(line, 1, config)).toEqual([
{
message: `Line has incorrect indentation - 3 spaces`,
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
}
])
})
it('should return an array with a single diagnostic when the line is indented incorrectly', () => {
const line = " %put 'hello';"
const config = new LintConfig({ indentationMultiple: 3 })
expect(indentationMultiple.test(line, 1, config)).toEqual([
{
message: `Line has incorrect indentation - 2 spaces`,
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
}
])
})
it('should fall back to a default of 2 spaces', () => {
const line = " %put 'hello';"
expect(indentationMultiple.test(line, 1)).toEqual([
{
message: `Line has incorrect indentation - 1 space`,
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
}
])
})
it('should return an empty array for lines within the default indentation', () => {
const line = " %put 'hello';"
expect(indentationMultiple.test(line, 1)).toEqual([])
})
})

View File

@@ -0,0 +1,41 @@
import { LintConfig } from '../types'
import { LineLintRule } from '../types/LintRule'
import { LintRuleType } from '../types/LintRuleType'
import { Severity } from '../types/Severity'
const name = 'indentationMultiple'
const description = 'Ensure indentation by a multiple of the configured number.'
const message = 'Line has incorrect indentation'
const test = (value: string, lineNumber: number, config?: LintConfig) => {
if (!value.startsWith(' ')) return []
const indentationMultiple = isNaN(config?.indentationMultiple as number)
? 2
: config!.indentationMultiple
if (indentationMultiple === 0) return []
const numberOfSpaces = value.search(/\S|$/)
if (numberOfSpaces % indentationMultiple! === 0) return []
return [
{
message: `${message} - ${numberOfSpaces} ${
numberOfSpaces === 1 ? 'space' : 'spaces'
}`,
lineNumber,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
}
]
}
/**
* Lint rule that checks if a line is indented by a multiple of the configured indentation multiple.
*/
export const indentationMultiple: LineLintRule = {
type: LintRuleType.Line,
name,
description,
message,
test
}

View File

@@ -0,0 +1,27 @@
import { Severity } from '../types/Severity'
import { lowerCaseFileNames } from './lowerCaseFileNames'
describe('lowerCaseFileNames', () => {
it('should return an empty array when the file name has no uppercase characters', () => {
const filePath = '/code/sas/my_sas_file.sas'
expect(lowerCaseFileNames.test(filePath)).toEqual([])
})
it('should return an empty array when the file name has no uppercase characters, even if the containing folder has uppercase characters', () => {
const filePath = '/code/SAS Projects/my_sas_file.sas'
expect(lowerCaseFileNames.test(filePath)).toEqual([])
})
it('should return an array with a single diagnostic when the file name has uppercase characters', () => {
const filePath = '/code/sas/my SAS file.sas'
expect(lowerCaseFileNames.test(filePath)).toEqual([
{
message: 'File name contains uppercase characters',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
}
])
})
})

View File

@@ -0,0 +1,32 @@
import { PathLintRule } from '../types/LintRule'
import { LintRuleType } from '../types/LintRuleType'
import { Severity } from '../types/Severity'
import path from 'path'
const name = 'lowerCaseFileNames'
const description = 'Enforce the use of lower case file names.'
const message = 'File name contains uppercase characters'
const test = (value: string) => {
const fileName = path.basename(value)
if (fileName.toLocaleLowerCase() === fileName) return []
return [
{
message,
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
}
]
}
/**
* Lint rule that checks for the absence of uppercase characters in a given file name.
*/
export const lowerCaseFileNames: PathLintRule = {
type: LintRuleType.Path,
name,
description,
message,
test
}

View File

@@ -0,0 +1,44 @@
import { LintConfig, Severity } from '../types'
import { maxLineLength } from './maxLineLength'
describe('maxLineLength', () => {
it('should return an empty array when the line is within the specified length', () => {
const line = "%put 'hello';"
const config = new LintConfig({ maxLineLength: 60 })
expect(maxLineLength.test(line, 1, config)).toEqual([])
})
it('should return an array with a single diagnostic when the line exceeds the specified length', () => {
const line = "%put 'hello';"
const config = new LintConfig({ maxLineLength: 10 })
expect(maxLineLength.test(line, 1, config)).toEqual([
{
message: `Line exceeds maximum length by 3 characters`,
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
}
])
})
it('should fall back to a default of 80 characters', () => {
const line =
'Prow scuttle parrel provost Sail ho shrouds spirits boom mizzenmast yardarm. Pinnace holystone.'
expect(maxLineLength.test(line, 1)).toEqual([
{
message: `Line exceeds maximum length by 15 characters`,
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
}
])
})
it('should return an empty array for lines within the default length', () => {
const line =
'Prow scuttle parrel provost Sail ho shrouds spirits boom mizzenmast yard'
expect(maxLineLength.test(line, 1)).toEqual([])
})
})

View File

@@ -0,0 +1,32 @@
import { LintConfig } from '../types'
import { LineLintRule } from '../types/LintRule'
import { LintRuleType } from '../types/LintRuleType'
import { Severity } from '../types/Severity'
const name = 'maxLineLength'
const description = 'Restrict lines to the specified length.'
const message = 'Line exceeds maximum length'
const test = (value: string, lineNumber: number, config?: LintConfig) => {
const maxLineLength = config?.maxLineLength || 80
if (value.length <= maxLineLength) return []
return [
{
message: `${message} by ${value.length - maxLineLength} characters`,
lineNumber,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
}
]
}
/**
* Lint rule that checks if a line has exceeded the configured maximum length.
*/
export const maxLineLength: LineLintRule = {
type: LintRuleType.Line,
name,
description,
message,
test
}

View File

@@ -1,7 +1,7 @@
import { PathLintRule } from '../types/LintRule'
import { LintRuleType } from '../types/LintRuleType'
import path from 'path'
import { Severity } from '../types/Severity'
import path from 'path'
const name = 'noSpacesInFileNames'
const description = 'Enforce the absence of spaces within file names.'

View File

@@ -0,0 +1,22 @@
import { Severity } from '../types/Severity'
import { noTabIndentation } from './noTabIndentation'
describe('noTabs', () => {
it('should return an empty array when the line is not indented with a tab', () => {
const line = "%put 'hello';"
expect(noTabIndentation.test(line, 1)).toEqual([])
})
it('should return an array with a single diagnostic when the line is indented with a tab', () => {
const line = "\t%put 'hello';"
expect(noTabIndentation.test(line, 1)).toEqual([
{
message: 'Line is indented with a tab',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
}
])
})
})

View File

@@ -0,0 +1,30 @@
import { LineLintRule } from '../types/LintRule'
import { LintRuleType } from '../types/LintRuleType'
import { Severity } from '../types/Severity'
const name = 'noTabs'
const description = 'Disallow indenting with tabs.'
const message = 'Line is indented with a tab'
const test = (value: string, lineNumber: number) => {
if (!value.startsWith('\t')) return []
return [
{
message,
lineNumber,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
}
]
}
/**
* Lint rule that checks if a given line of text is indented with a tab.
*/
export const noTabIndentation: LineLintRule = {
type: LintRuleType.Line,
name,
description,
message,
test
}

View File

@@ -40,6 +40,20 @@ describe('LintConfig', () => {
expect(config.fileLintRules[0].type).toEqual(LintRuleType.File)
})
it('should create an instance with the indentation multiple set', () => {
const config = new LintConfig({ indentationMultiple: 5 })
expect(config).toBeTruthy()
expect(config.indentationMultiple).toEqual(5)
})
it('should create an instance with the indentation multiple turned off', () => {
const config = new LintConfig({ indentationMultiple: 0 })
expect(config).toBeTruthy()
expect(config.indentationMultiple).toEqual(0)
})
it('should create an instance with all flags set', () => {
const config = new LintConfig({
noTrailingSpaces: true,

View File

@@ -1,6 +1,10 @@
import { hasDoxygenHeader } from '../rules/hasDoxygenHeader'
import { indentationMultiple } from '../rules/indentationMultiple'
import { lowerCaseFileNames } from '../rules/lowerCaseFileNames'
import { maxLineLength } from '../rules/maxLineLength'
import { noEncodedPasswords } from '../rules/noEncodedPasswords'
import { noSpacesInFileNames } from '../rules/noSpacesInFileNames'
import { noTabIndentation } from '../rules/noTabIndentation'
import { noTrailingSpaces } from '../rules/noTrailingSpaces'
import { FileLintRule, LineLintRule, PathLintRule } from './LintRule'
@@ -15,6 +19,8 @@ export class LintConfig {
readonly lineLintRules: LineLintRule[] = []
readonly fileLintRules: FileLintRule[] = []
readonly pathLintRules: PathLintRule[] = []
readonly maxLineLength: number = 80
readonly indentationMultiple: number = 2
constructor(json?: any) {
if (json?.noTrailingSpaces) {
@@ -25,6 +31,20 @@ export class LintConfig {
this.lineLintRules.push(noEncodedPasswords)
}
if (json?.noTabIndentation) {
this.lineLintRules.push(noTabIndentation)
}
if (json?.maxLineLength) {
this.maxLineLength = json.maxLineLength
this.lineLintRules.push(maxLineLength)
}
if (!isNaN(json?.indentationMultiple)) {
this.indentationMultiple = json.indentationMultiple as number
this.lineLintRules.push(indentationMultiple)
}
if (json?.hasDoxygenHeader) {
this.fileLintRules.push(hasDoxygenHeader)
}
@@ -32,5 +52,9 @@ export class LintConfig {
if (json?.noSpacesInFileNames) {
this.pathLintRules.push(noSpacesInFileNames)
}
if (json?.lowerCaseFileNames) {
this.pathLintRules.push(lowerCaseFileNames)
}
}
}

View File

@@ -1,4 +1,5 @@
import { Diagnostic } from './Diagnostic'
import { LintConfig } from './LintConfig'
import { LintRuleType } from './LintRuleType'
/**
@@ -17,7 +18,7 @@ export interface LintRule {
*/
export interface LineLintRule extends LintRule {
type: LintRuleType.Line
test: (value: string, lineNumber: number) => Diagnostic[]
test: (value: string, lineNumber: number, config?: LintConfig) => Diagnostic[]
}
/**

6
src/types/Process.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare namespace NodeJS {
export interface Process {
projectDir: string
currentDir: string
}
}

View File

@@ -0,0 +1,15 @@
import { asyncForEach } from './asyncForEach'
describe('asyncForEach', () => {
it('should execute the async callback for each item in the given array', async () => {
const callback = jest.fn().mockImplementation(() => Promise.resolve())
const array = [1, 2, 3]
await asyncForEach(array, callback)
expect(callback.mock.calls.length).toEqual(3)
expect(callback.mock.calls[0]).toEqual([1, 0, array])
expect(callback.mock.calls[1]).toEqual([2, 1, array])
expect(callback.mock.calls[2]).toEqual([3, 2, array])
})
})

View File

@@ -0,0 +1,8 @@
export async function asyncForEach(
array: any[],
callback: (item: any, index: number, originalArray: any[]) => any
) {
for (let index = 0; index < array.length; index++) {
await callback(array[index], index, array)
}
}

View File

@@ -18,6 +18,7 @@ describe('getLintConfig', () => {
expect(config).toBeInstanceOf(LintConfig)
expect(config.fileLintRules.length).toEqual(1)
expect(config.lineLintRules.length).toEqual(2)
expect(config.lineLintRules.length).toEqual(5)
expect(config.pathLintRules.length).toEqual(2)
})
})

View File

@@ -3,11 +3,20 @@ import { LintConfig } from '../types/LintConfig'
import { readFile } from '@sasjs/utils/file'
import { getProjectRoot } from './getProjectRoot'
const defaultConfiguration = {
/**
* Default configuration that is used when a .sasjslint file is not found
*/
export const DefaultLintConfiguration = {
noTrailingSpaces: true,
noEncodedPasswords: true,
hasDoxygenHeader: true
hasDoxygenHeader: true,
noSpacesInFileNames: true,
lowerCaseFileNames: true,
maxLineLength: 80,
noTabIndentation: true,
indentationMultiple: 2
}
/**
* Fetches the config from the .sasjslint file and creates a LintConfig object.
* Returns the default configuration when a .sasjslint file is unavailable.
@@ -19,7 +28,7 @@ export async function getLintConfig(): Promise<LintConfig> {
path.join(projectRoot, '.sasjslint')
).catch((_) => {
console.warn('Unable to load .sasjslint file. Using default configuration.')
return JSON.stringify(defaultConfiguration)
return JSON.stringify(DefaultLintConfiguration)
})
return new LintConfig(JSON.parse(configuration))
}

3
src/utils/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './getLintConfig'
export * from './getProjectRoot'
export * from './listSasFiles'

View File

@@ -0,0 +1,6 @@
import { listFilesInFolder } from '@sasjs/utils/file'
export const listSasFiles = async (folderPath: string): Promise<string[]> => {
const files = await listFilesInFolder(folderPath)
return files.filter((f) => f.endsWith('.sas'))
}