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

Compare commits

...

55 Commits

Author SHA1 Message Date
Yury Shkoda
5a358330c0 Merge pull request #50 from sasjs/git-hooks
feat(git): enabled git hook enforcing conventional commits
2021-05-20 09:01:47 +03:00
Yury Shkoda
fa9e4136bc chore(lint): fixed linting 2021-05-20 08:58:25 +03:00
Yury Shkoda
0c9b23c51b feat(git): enabled git hook enforcing conventional commits 2021-05-20 08:54:36 +03:00
Allan Bowe
9daf8f8c82 Update README.md 2021-05-06 11:02:54 +03:00
Allan Bowe
7ed846e3aa setting macronameinmend to true in readme 2021-05-06 11:02:29 +03:00
Krishna Acondy
f7f989fabd Merge pull request #44 from sasjs/dependabot/npm_and_yarn/types/node-15.0.2
chore(deps-dev): bump @types/node from 15.0.1 to 15.0.2
2021-05-06 07:26:30 +01:00
Krishna Acondy
850cf85ef1 Merge branch 'main' into dependabot/npm_and_yarn/types/node-15.0.2 2021-05-06 07:25:32 +01:00
Krishna Acondy
3dc304fffc Merge pull request #42 from sasjs/format-diagnostics
feat(format-diagnostics): add diagnostic information to format result payload
2021-05-06 07:25:10 +01:00
Krishna Acondy
e329529484 chore(*): add comment 2021-05-06 07:24:07 +01:00
Krishna Acondy
15190bfe88 Merge branch 'format-diagnostics' of https://github.com/sasjs/lint into format-diagnostics 2021-05-06 07:22:32 +01:00
Krishna Acondy
bc011c4b47 chore(*): add tests for new functionality 2021-05-06 07:22:29 +01:00
dependabot[bot]
a95c083b61 chore(deps-dev): bump @types/node from 15.0.1 to 15.0.2
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 15.0.1 to 15.0.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-05 08:27:22 +00:00
Krishna Acondy
96fb384ec9 Merge branch 'main' into format-diagnostics 2021-05-04 08:52:07 +01:00
Krishna Acondy
21fd4e8fcc Merge pull request #39 from sasjs/dependabot/add-v2-config-file
Upgrade to GitHub-native Dependabot
2021-05-04 08:51:54 +01:00
Krishna Acondy
ac595c65d0 Merge branch 'main' into dependabot/add-v2-config-file 2021-05-04 08:50:58 +01:00
Krishna Acondy
e5763ce529 Merge pull request #33 from sasjs/dependabot/npm_and_yarn/ts-jest-26.5.5
chore(deps-dev): bump ts-jest from 26.5.4 to 26.5.5
2021-05-04 08:50:29 +01:00
Krishna Acondy
4729f04589 Merge branch 'main' into dependabot/npm_and_yarn/ts-jest-26.5.5 2021-05-04 08:49:35 +01:00
dependabot-preview[bot]
596d56c906 chore(deps-dev): bump ts-jest from 26.5.4 to 26.5.5
Bumps [ts-jest](https://github.com/kulshekhar/ts-jest) from 26.5.4 to 26.5.5.
- [Release notes](https://github.com/kulshekhar/ts-jest/releases)
- [Changelog](https://github.com/kulshekhar/ts-jest/blob/master/CHANGELOG.md)
- [Commits](https://github.com/kulshekhar/ts-jest/compare/v26.5.4...v26.5.5)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2021-05-04 07:48:11 +00:00
Krishna Acondy
32956db8b2 chore(*): Add dependabot badge 2021-05-04 08:47:30 +01:00
Krishna Acondy
7b58c455dc Merge pull request #36 from sasjs/dependabot/npm_and_yarn/types/jest-26.0.23
chore(deps-dev): bump @types/jest from 26.0.21 to 26.0.23
2021-05-04 08:46:43 +01:00
dependabot-preview[bot]
c86fd7dd1d chore(deps-dev): bump @types/jest from 26.0.21 to 26.0.23
Bumps [@types/jest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jest) from 26.0.21 to 26.0.23.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jest)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2021-05-04 07:45:39 +00:00
Krishna Acondy
34e9a7b139 Merge pull request #30 from sasjs/dependabot/npm_and_yarn/typescript-4.2.4
chore(deps-dev): bump typescript from 4.2.3 to 4.2.4
2021-05-04 08:44:11 +01:00
dependabot-preview[bot]
5de3d33c1c chore(deps-dev): bump typescript from 4.2.3 to 4.2.4
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 4.2.3 to 4.2.4.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v4.2.3...v4.2.4)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2021-05-04 07:41:55 +00:00
Krishna Acondy
3a6a5d30e3 Merge pull request #38 from sasjs/dependabot/npm_and_yarn/types/node-15.0.1
chore(deps-dev): bump @types/node from 14.14.35 to 15.0.1
2021-05-04 08:40:26 +01:00
Krishna Acondy
0f629c4aca chore(*): remove unnecessary ignores 2021-05-04 08:39:23 +01:00
dependabot-preview[bot]
ae4c5e8347 chore(deps-dev): bump @types/node from 14.14.35 to 15.0.1
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 14.14.35 to 15.0.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2021-05-04 07:38:19 +00:00
Krishna Acondy
3c700a97fc Merge branch 'main' into dependabot/add-v2-config-file 2021-05-04 08:37:03 +01:00
Krishna Acondy
d113ef4ddd Merge pull request #41 from sasjs/dependabot/npm_and_yarn/sasjs/utils-2.12.0
chore(deps): bump @sasjs/utils from 2.10.1 to 2.12.0
2021-05-04 08:36:48 +01:00
Krishna Acondy
dce9453680 feat(format-diagnostics): add diagnostic information to format result payload 2021-05-04 08:27:06 +01:00
dependabot-preview[bot]
e76abc2db2 chore(deps): bump @sasjs/utils from 2.10.1 to 2.12.0
Bumps [@sasjs/utils](https://github.com/sasjs/utils) from 2.10.1 to 2.12.0.
- [Release notes](https://github.com/sasjs/utils/releases)
- [Commits](https://github.com/sasjs/utils/compare/v2.10.1...v2.12.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2021-05-04 06:30:57 +00:00
dependabot-preview[bot]
1e70b9debc Upgrade to GitHub-native Dependabot 2021-04-28 22:45:36 +00:00
Krishna Acondy
984915fe47 Merge pull request #29 from sasjs/line-ending-formatting
feat(*): add line endings rule, add automatic formatting for fixable violations
2021-04-21 15:24:08 +01:00
Krishna Acondy
2687a8fa46 chore(*): separate tests for test and fix functions 2021-04-21 15:22:02 +01:00
Krishna Acondy
3da3e1e134 fix(macros): check for exact match with macro name 2021-04-21 15:17:16 +01:00
Krishna Acondy
abc2f75dc0 chore(*): rename macro properties 2021-04-21 15:10:28 +01:00
Saad Jutt
060b838f21 test(*): removed extra lineEndings 2021-04-21 16:51:13 +05:00
Saad Jutt
cd90b0850a fix(hasMacroParentheses): added additional test also 2021-04-21 16:44:20 +05:00
Saad Jutt
db2dbb1c69 feat(format): rules for hasMacroNameInMend 2021-04-21 16:25:36 +05:00
Saad Jutt
59f7e71919 tests(hasMacroNameInMend): Added more 2021-04-21 03:31:14 +05:00
Saad Jutt
6fd941aa2d tests(hasMacroNameInMend): Added more 2021-04-21 03:27:24 +05:00
Krishna Acondy
93124bec5b chore(*): revert change to example file 2021-04-19 22:15:11 +01:00
Krishna Acondy
bcb50b9968 feat(format): add the ability to format files, folders and projects 2021-04-19 22:13:53 +01:00
Krishna Acondy
d28d32d441 fix(*): add SAS Macros section to Doxygen header 2021-04-19 21:07:24 +01:00
Krishna Acondy
519a0164b5 feat(*): add line endings rule, add automatic formatting for fixable violations 2021-04-19 21:00:38 +01:00
Krishna Acondy
99813f04c0 chore(*): fix tests 2021-04-19 20:55:59 +01:00
Krishna Acondy
eb5a1bbbcb Revert "feat(*): add line endings rule, add automatic formatting for fixable violations"
This reverts commit 33a57c3163.
2021-04-19 20:46:38 +01:00
Krishna Acondy
0c22ade942 Merge branch 'main' of https://github.com/sasjs/lint into main 2021-04-19 20:06:51 +01:00
Krishna Acondy
33a57c3163 feat(*): add line endings rule, add automatic formatting for fixable violations 2021-04-19 20:06:45 +01:00
Krishna Acondy
c2209cbe0e Merge pull request #27 from sasjs/issue-26
fix(hasMacroNameInMend): default sets to true
2021-04-16 13:24:40 +01:00
Saad Jutt
fe974050f7 chore: replaced with Severity.Warning 2021-04-16 16:20:02 +05:00
Saad Jutt
1402802f0a tests: added expectedDiagnostics array 2021-04-16 16:16:22 +05:00
Saad Jutt
36b3a7f319 chore(tests): changed numeric literals -> consts 2021-04-15 20:45:27 +05:00
Saad Jutt
c56887d6e6 fix(hasMacroNameInMend): default sets to true 2021-04-15 16:33:31 +05:00
Krishna Acondy
031a323839 Merge pull request #25 from sasjs/group-rules
chore(*): organise rules into folders by type
2021-04-07 16:42:37 +01:00
Krishna Acondy
c9b6c3af95 chore(*): organise rules into folders by type 2021-04-07 16:34:33 +01:00
71 changed files with 2182 additions and 610 deletions

18
.git-hooks/commit-msg Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/sh
RED="\033[1;31m"
GREEN="\033[1;32m"
# Get the commit message (the parameter we're given is just the path to the
# temporary file which holds the message).
commit_message=$(cat "$1")
if (echo "$commit_message" | grep -Eq "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z \-]+\))?!?: .+$") then
echo "${GREEN} ✔ Commit message meets Conventional Commit standards"
exit 0
fi
echo "${RED}❌ Commit message does not meet the Conventional Commit standard!"
echo "An example of a valid message is:"
echo " feat(login): add the 'remember me' button"
echo " More details at: https://www.conventionalcommits.org/en/v1.0.0/#summary"
exit 1

7
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10

View File

@@ -7,7 +7,7 @@
"lowerCaseFileNames": true, "lowerCaseFileNames": true,
"noTabIndentation": true, "noTabIndentation": true,
"indentationMultiple": 2, "indentationMultiple": 2,
"hasMacroNameInMend": false, "hasMacroNameInMend": true,
"noNestedMacros": true, "noNestedMacros": true,
"hasMacroParentheses": true "hasMacroParentheses": true
} }

View File

@@ -1,3 +1,5 @@
[![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=sasjs/lint)](https://dependabot.com)
# SAS Code linting and formatting # SAS Code linting and formatting
Our goal is to help SAS developers everywhere spend less time on code reviews, bug fixing and arguing about standards - and more time delivering extraordinary business value. Our goal is to help SAS developers everywhere spend less time on code reviews, bug fixing and arguing about standards - and more time delivering extraordinary business value.
@@ -14,7 +16,7 @@ Configuration is via a `.sasjslint` file with the following structure (these are
{ {
"noEncodedPasswords": true, "noEncodedPasswords": true,
"hasDoxygenHeader": true, "hasDoxygenHeader": true,
"hasMacroNameInMend": false, "hasMacroNameInMend": true,
"hasMacroParentheses": true, "hasMacroParentheses": true,
"indentationMultiple": 2, "indentationMultiple": 2,
"lowerCaseFileNames": true, "lowerCaseFileNames": true,
@@ -44,7 +46,7 @@ The SASjs framework recommends the use of Doxygen headers for describing all typ
#### hasMacroNameInMend #### hasMacroNameInMend
The addition of the macro name in the `%mend` statement is optional, but can approve readability in large programs. A discussion on this topic can be found [here](https://www.linkedin.com/posts/allanbowe_sas-sasapps-sasjs-activity-6783413360781266945-1-7m). The default setting will be the result of a popular vote by around 300 people. The addition of the macro name in the `%mend` statement is optional, but can approve readability in large programs. A discussion on this topic can be found [here](https://www.linkedin.com/posts/allanbowe_sas-sasapps-sasjs-activity-6783413360781266945-1-7m). The default setting will be the result of a popular vote by around 300 people.
* Default: false (for now) * Default: true
* Severity: WARNING * Severity: WARNING
#### hasMacroParentheses #### hasMacroParentheses

View File

@@ -9,5 +9,5 @@ module.exports = {
statements: -10 statements: -10
} }
}, },
collectCoverageFrom: ['src/**/{!(index|example),}.ts'] collectCoverageFrom: ['src/**/{!(index|formatExample|lintExample),}.ts']
} }

93
package-lock.json generated
View File

@@ -648,14 +648,35 @@
} }
}, },
"@sasjs/utils": { "@sasjs/utils": {
"version": "2.10.1", "version": "2.12.0",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-2.12.0.tgz",
"integrity": "sha512-T54jx6NEMLu2+R/ux4qcb3dDJ7nFrKkPCkmPXEfZxPQBkbq4C0kmaZv6dC63RDH68wYhoXR2S5fION5fFh91iw==", "integrity": "sha512-OnC/7R+nGI8tlSPCcI7fPyD7T97B+McnkXT0IuAYDNGbfwRPuseWq0I1h+kbAWThGT67H4hnp61N0qr8LkpHZQ==",
"requires": { "requires": {
"@types/prompts": "^2.0.9", "chalk": "^4.1.1",
"cli-table": "^0.3.6",
"consola": "^2.15.0", "consola": "^2.15.0",
"prompts": "^2.4.0", "prompts": "^2.4.1",
"valid-url": "^1.0.9" "valid-url": "^1.0.9"
},
"dependencies": {
"chalk": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
"integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"prompts": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.1.tgz",
"integrity": "sha512-EQyfIuO2hPDsX1L/blblV+H7I0knhgAd82cVneCwcdND9B8AuCDuRcBH6yIcG4dFzlOUqbazQqwGjx5xmsNLuQ==",
"requires": {
"kleur": "^3.0.3",
"sisteransi": "^1.0.5"
}
}
} }
}, },
"@sinonjs/commons": { "@sinonjs/commons": {
@@ -751,9 +772,9 @@
} }
}, },
"@types/jest": { "@types/jest": {
"version": "26.0.21", "version": "26.0.23",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.21.tgz", "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.23.tgz",
"integrity": "sha512-ab9TyM/69yg7eew9eOwKMUmvIZAKEGZYlq/dhe5/0IMUd/QLJv5ldRMdddSn+u22N13FP3s5jYyktxuBwY0kDA==", "integrity": "sha512-ZHLmWMJ9jJ9PTiT58juykZpL7KjwJywFN3Rr2pTSkyQfydf/rk22yS7W8p5DaVUMQ2BQC7oYiU3FjbTM/mYrOA==",
"dev": true, "dev": true,
"requires": { "requires": {
"jest-diff": "^26.0.0", "jest-diff": "^26.0.0",
@@ -761,9 +782,10 @@
} }
}, },
"@types/node": { "@types/node": {
"version": "14.14.35", "version": "15.0.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.35.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-15.0.2.tgz",
"integrity": "sha512-Lt+wj8NVPx0zUmUwumiVXapmaLUcAk3yPuHCFVXras9k5VT9TdhJqKqGVUQCD60OTMCl0qxJ57OiTL0Mic3Iag==" "integrity": "sha512-p68+a+KoxpoB47015IeYZYRrdqMUcpbK8re/zpFB8Ld46LHC1lPEbp3EXgkEhAYEcPvjJF6ZO+869SQ0aH1dcA==",
"dev": true
}, },
"@types/normalize-package-data": { "@types/normalize-package-data": {
"version": "2.4.0", "version": "2.4.0",
@@ -777,14 +799,6 @@
"integrity": "sha512-PijRCG/K3s3w1We6ynUKdxEc5AcuuH3NBmMDP8uvKVp6X43UY7NQlTzczakXP3DJR0F4dfNQIGjU2cUeRYs2AA==", "integrity": "sha512-PijRCG/K3s3w1We6ynUKdxEc5AcuuH3NBmMDP8uvKVp6X43UY7NQlTzczakXP3DJR0F4dfNQIGjU2cUeRYs2AA==",
"dev": true "dev": true
}, },
"@types/prompts": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.0.10.tgz",
"integrity": "sha512-W3PEl3l4vmxdgfY6LUG7ysh+mLJOTOFYmSpiLe6MCo1OdEm8b5s6ZJfuTQgEpYNwcMiiaRzJespPS5Py2tqLlQ==",
"requires": {
"@types/node": "*"
}
},
"@types/stack-utils": { "@types/stack-utils": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.0.tgz",
@@ -881,7 +895,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"requires": { "requires": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
} }
@@ -1278,6 +1291,14 @@
} }
} }
}, },
"cli-table": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/cli-table/-/cli-table-0.3.6.tgz",
"integrity": "sha512-ZkNZbnZjKERTY5NwC2SeMeLeifSPq/pubeRoTpdr3WchLlnZg6hEgvHkK5zL7KNFdd9PmHN8lxrENUwI3cE8vQ==",
"requires": {
"colors": "1.0.3"
}
},
"cliui": { "cliui": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
@@ -1315,7 +1336,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"requires": { "requires": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
} }
@@ -1323,8 +1343,7 @@
"color-name": { "color-name": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
"dev": true
}, },
"colorette": { "colorette": {
"version": "1.2.2", "version": "1.2.2",
@@ -1332,6 +1351,11 @@
"integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==", "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==",
"dev": true "dev": true
}, },
"colors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz",
"integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs="
},
"combined-stream": { "combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -2036,8 +2060,7 @@
"has-flag": { "has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
"dev": true
}, },
"has-value": { "has-value": {
"version": "1.0.0", "version": "1.0.0",
@@ -3587,6 +3610,7 @@
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.0.tgz", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.0.tgz",
"integrity": "sha512-awZAKrk3vN6CroQukBL+R9051a4R3zCZBlJm/HBfrSZ8iTpYix3VX1vU4mveiLpiwmOJT4wokTF9m6HUk4KqWQ==", "integrity": "sha512-awZAKrk3vN6CroQukBL+R9051a4R3zCZBlJm/HBfrSZ8iTpYix3VX1vU4mveiLpiwmOJT4wokTF9m6HUk4KqWQ==",
"dev": true,
"requires": { "requires": {
"kleur": "^3.0.3", "kleur": "^3.0.3",
"sisteransi": "^1.0.5" "sisteransi": "^1.0.5"
@@ -4395,7 +4419,6 @@
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"requires": { "requires": {
"has-flag": "^4.0.0" "has-flag": "^4.0.0"
} }
@@ -4517,9 +4540,9 @@
} }
}, },
"ts-jest": { "ts-jest": {
"version": "26.5.4", "version": "26.5.5",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-26.5.4.tgz", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-26.5.5.tgz",
"integrity": "sha512-I5Qsddo+VTm94SukBJ4cPimOoFZsYTeElR2xy6H2TOVs+NsvgYglW8KuQgKoApOKuaU/Ix/vrF9ebFZlb5D2Pg==", "integrity": "sha512-7tP4m+silwt1NHqzNRAPjW1BswnAhopTdc2K3HEkRZjF0ZG2F/e/ypVH0xiZIMfItFtD3CX0XFbwPzp9fIEUVg==",
"dev": true, "dev": true,
"requires": { "requires": {
"bs-logger": "0.x", "bs-logger": "0.x",
@@ -4535,9 +4558,9 @@
}, },
"dependencies": { "dependencies": {
"semver": { "semver": {
"version": "7.3.4", "version": "7.3.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
"integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
"dev": true, "dev": true,
"requires": { "requires": {
"lru-cache": "^6.0.0" "lru-cache": "^6.0.0"
@@ -4597,9 +4620,9 @@
} }
}, },
"typescript": { "typescript": {
"version": "4.2.3", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz",
"integrity": "sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw==", "integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==",
"dev": true "dev": true
}, },
"union-value": { "union-value": {

View File

@@ -8,7 +8,8 @@
"postpublish": "git clean -fd", "postpublish": "git clean -fd",
"package:lib": "npm run build && cp ./package.json build && cp README.md build && cd build && npm version \"5.0.0\" && npm pack", "package:lib": "npm run build && cp ./package.json build && cp README.md build && cd build && npm version \"5.0.0\" && npm pack",
"lint:fix": "npx prettier --write '{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}'", "lint:fix": "npx prettier --write '{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}'",
"lint": "npx prettier --check '{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}'" "lint": "npx prettier --check '{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}'",
"postinstall": "[ -d .git ] && git config core.hooksPath ./.git-hooks || true"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
@@ -36,14 +37,14 @@
}, },
"homepage": "https://github.com/sasjs/lint#readme", "homepage": "https://github.com/sasjs/lint#readme",
"devDependencies": { "devDependencies": {
"@types/jest": "^26.0.21", "@types/jest": "^26.0.23",
"@types/node": "^14.14.35", "@types/node": "^15.0.2",
"jest": "^26.6.3", "jest": "^26.6.3",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"ts-jest": "^26.5.4", "ts-jest": "^26.5.5",
"typescript": "^4.2.3" "typescript": "^4.2.4"
}, },
"dependencies": { "dependencies": {
"@sasjs/utils": "^2.10.1" "@sasjs/utils": "^2.12.0"
} }
} }

View File

@@ -15,7 +15,8 @@
"noNestedMacros": true, "noNestedMacros": true,
"noSpacesInFileNames": true, "noSpacesInFileNames": true,
"noTabIndentation": true, "noTabIndentation": true,
"noTrailingSpaces": true "noTrailingSpaces": true,
"lineEndings": "lf"
}, },
"examples": [ "examples": [
{ {
@@ -29,7 +30,8 @@
"indentationMultiple": 4, "indentationMultiple": 4,
"hasMacroNameInMend": true, "hasMacroNameInMend": true,
"noNestedMacros": true, "noNestedMacros": true,
"hasMacroParentheses": true "hasMacroParentheses": true,
"lineEndings": "crlf"
} }
], ],
"properties": { "properties": {
@@ -120,6 +122,14 @@
"description": "Enforces no trailing spaces in lines of SAS code. Shows a warning when they are present.", "description": "Enforces no trailing spaces in lines of SAS code. Shows a warning when they are present.",
"default": true, "default": true,
"examples": [true, false] "examples": [true, false]
},
"lineEndings": {
"$id": "#/properties/lineEndings",
"type": "string",
"title": "lineEndings",
"description": "Enforces the configured terminating character for each line. Shows a warning when incorrect line endings are present.",
"default": "lf",
"examples": ["lf", "crlf"]
} }
} }
} }

View File

@@ -1 +0,0 @@
export const format = (text: string) => {}

View File

@@ -0,0 +1,97 @@
import { formatFile } from './formatFile'
import path from 'path'
import { createFile, deleteFile, readFile } from '@sasjs/utils/file'
import { LintConfig } from '../types'
describe('formatFile', () => {
it('should fix linting issues in a given file', async () => {
const content = `%macro somemacro(); \n%put 'hello';\n%mend;`
const expectedContent = `/**\n @file\n @brief <Your brief here>\n <h4> SAS Macros </h4>\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;`
await createFile(path.join(__dirname, 'format-file-test.sas'), content)
const expectedResult = {
updatedFilePaths: [path.join(__dirname, 'format-file-test.sas')],
fixedDiagnosticsCount: 3,
unfixedDiagnostics: []
}
const result = await formatFile(
path.join(__dirname, 'format-file-test.sas')
)
const formattedContent = await readFile(
path.join(__dirname, 'format-file-test.sas')
)
expect(result).toEqual(expectedResult)
expect(formattedContent).toEqual(expectedContent)
await deleteFile(path.join(__dirname, 'format-file-test.sas'))
})
it('should use the provided config if available', async () => {
const content = `%macro somemacro(); \n%put 'hello';\n%mend;`
const expectedContent = `/**\r\n @file\r\n @brief <Your brief here>\r\n <h4> SAS Macros </h4>\r\n**/\r\n%macro somemacro();\r\n%put 'hello';\r\n%mend;`
const expectedResult = {
updatedFilePaths: [path.join(__dirname, 'format-file-config.sas')],
fixedDiagnosticsCount: 2,
unfixedDiagnostics: [
{
endColumnNumber: 7,
lineNumber: 8,
message: '%mend statement is missing macro name - somemacro',
severity: 1,
startColumnNumber: 1
}
]
}
await createFile(path.join(__dirname, 'format-file-config.sas'), content)
const result = await formatFile(
path.join(__dirname, 'format-file-config.sas'),
new LintConfig({
lineEndings: 'crlf',
hasMacroNameInMend: false,
hasDoxygenHeader: true,
noTrailingSpaces: true
})
)
const formattedContent = await readFile(
path.join(__dirname, 'format-file-config.sas')
)
expect(result).toEqual(expectedResult)
expect(formattedContent).toEqual(expectedContent)
await deleteFile(path.join(__dirname, 'format-file-config.sas'))
})
it('should not update any files if there are no formatting violations', async () => {
const content = `/**\r\n @file\r\n @brief <Your brief here>\r\n <h4> SAS Macros </h4>\r\n**/\r\n%macro somemacro();\r\n%put 'hello';\r\n%mend somemacro;`
const expectedResult = {
updatedFilePaths: [],
fixedDiagnosticsCount: 0,
unfixedDiagnostics: []
}
await createFile(
path.join(__dirname, 'format-file-no-violations.sas'),
content
)
const result = await formatFile(
path.join(__dirname, 'format-file-no-violations.sas'),
new LintConfig({
lineEndings: 'crlf',
hasMacroNameInMend: true,
hasDoxygenHeader: true,
noTrailingSpaces: true
})
)
const formattedContent = await readFile(
path.join(__dirname, 'format-file-no-violations.sas')
)
expect(result).toEqual(expectedResult)
expect(formattedContent).toEqual(content)
await deleteFile(path.join(__dirname, 'format-file-no-violations.sas'))
})
})

45
src/format/formatFile.ts Normal file
View File

@@ -0,0 +1,45 @@
import { createFile, readFile } from '@sasjs/utils/file'
import { lintFile } from '../lint'
import { FormatResult } from '../types'
import { LintConfig } from '../types/LintConfig'
import { getLintConfig } from '../utils/getLintConfig'
import { processText } from './shared'
/**
* Applies automatic formatting to the file at the given path.
* @param {string} filePath - the path to the file to be formatted.
* @param {LintConfig} configuration - an optional configuration. When not passed in, this is read from the .sasjslint file.
* @returns {Promise<FormatResult>} Resolves successfully when the file has been formatted.
*/
export const formatFile = async (
filePath: string,
configuration?: LintConfig
): Promise<FormatResult> => {
const config = configuration || (await getLintConfig())
const diagnosticsBeforeFormat = await lintFile(filePath)
const diagnosticsCountBeforeFormat = diagnosticsBeforeFormat.length
const text = await readFile(filePath)
const formattedText = processText(text, config)
await createFile(filePath, formattedText)
const diagnosticsAfterFormat = await lintFile(filePath)
const diagnosticsCountAfterFormat = diagnosticsAfterFormat.length
const fixedDiagnosticsCount =
diagnosticsCountBeforeFormat - diagnosticsCountAfterFormat
const updatedFilePaths: string[] = []
if (fixedDiagnosticsCount) {
updatedFilePaths.push(filePath)
}
return {
updatedFilePaths,
fixedDiagnosticsCount,
unfixedDiagnostics: diagnosticsAfterFormat
}
}

View File

@@ -0,0 +1,228 @@
import { formatFolder } from './formatFolder'
import path from 'path'
import {
createFile,
createFolder,
deleteFolder,
readFile
} from '@sasjs/utils/file'
import { Diagnostic, LintConfig } from '../types'
describe('formatFolder', () => {
it('should fix linting issues in a given folder', async () => {
const content = `%macro somemacro(); \n%put 'hello';\n%mend;`
const expectedContent = `/**\n @file\n @brief <Your brief here>\n <h4> SAS Macros </h4>\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;`
const expectedResult = {
updatedFilePaths: [
path.join(__dirname, 'format-folder-test', 'format-folder-test.sas')
],
fixedDiagnosticsCount: 3,
unfixedDiagnostics: new Map<string, Diagnostic[]>([
[
path.join(__dirname, 'format-folder-test', 'format-folder-test.sas'),
[]
]
])
}
await createFolder(path.join(__dirname, 'format-folder-test'))
await createFile(
path.join(__dirname, 'format-folder-test', 'format-folder-test.sas'),
content
)
const result = await formatFolder(
path.join(__dirname, 'format-folder-test')
)
const formattedContent = await readFile(
path.join(__dirname, 'format-folder-test', 'format-folder-test.sas')
)
expect(formattedContent).toEqual(expectedContent)
expect(result).toEqual(expectedResult)
await deleteFolder(path.join(__dirname, 'format-folder-test'))
})
it('should fix linting issues in subfolders of a given folder', async () => {
const content = `%macro somemacro(); \n%put 'hello';\n%mend;`
const expectedContent = `/**\n @file\n @brief <Your brief here>\n <h4> SAS Macros </h4>\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;`
const expectedResult = {
updatedFilePaths: [
path.join(
__dirname,
'format-folder-test',
'subfolder',
'format-folder-test.sas'
)
],
fixedDiagnosticsCount: 3,
unfixedDiagnostics: new Map<string, Diagnostic[]>([
[
path.join(
__dirname,
'format-folder-test',
'subfolder',
'format-folder-test.sas'
),
[]
]
])
}
await createFolder(path.join(__dirname, 'format-folder-test'))
await createFolder(path.join(__dirname, 'subfolder'))
await createFile(
path.join(
__dirname,
'format-folder-test',
'subfolder',
'format-folder-test.sas'
),
content
)
const result = await formatFolder(
path.join(__dirname, 'format-folder-test')
)
const formattedContent = await readFile(
path.join(
__dirname,
'format-folder-test',
'subfolder',
'format-folder-test.sas'
)
)
expect(result).toEqual(expectedResult)
expect(formattedContent).toEqual(expectedContent)
await deleteFolder(path.join(__dirname, 'format-folder-test'))
})
it('should use a custom configuration when provided', async () => {
const content = `%macro somemacro(); \n%put 'hello';\n%mend;`
const expectedContent = `/**\n @file\n @brief <Your brief here>\n <h4> SAS Macros </h4>\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;`
const expectedResult = {
updatedFilePaths: [
path.join(__dirname, 'format-folder-test', 'format-folder-test.sas')
],
fixedDiagnosticsCount: 3,
unfixedDiagnostics: new Map<string, Diagnostic[]>([
[
path.join(__dirname, 'format-folder-test', 'format-folder-test.sas'),
[]
]
])
}
await createFolder(path.join(__dirname, 'format-folder-test'))
await createFile(
path.join(__dirname, 'format-folder-test', 'format-folder-test.sas'),
content
)
const result = await formatFolder(
path.join(__dirname, 'format-folder-test'),
new LintConfig({
lineEndings: 'crlf',
hasMacroNameInMend: false,
hasDoxygenHeader: true,
noTrailingSpaces: true
})
)
const formattedContent = await readFile(
path.join(__dirname, 'format-folder-test', 'format-folder-test.sas')
)
expect(formattedContent).toEqual(expectedContent)
expect(result).toEqual(expectedResult)
await deleteFolder(path.join(__dirname, 'format-folder-test'))
})
it('should fix linting issues in subfolders of a given folder', async () => {
const content = `%macro somemacro(); \n%put 'hello';\n%mend;`
const expectedContent = `/**\n @file\n @brief <Your brief here>\n <h4> SAS Macros </h4>\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;`
const expectedResult = {
updatedFilePaths: [
path.join(
__dirname,
'format-folder-test',
'subfolder',
'format-folder-test.sas'
)
],
fixedDiagnosticsCount: 3,
unfixedDiagnostics: new Map<string, Diagnostic[]>([
[
path.join(
__dirname,
'format-folder-test',
'subfolder',
'format-folder-test.sas'
),
[]
]
])
}
await createFolder(path.join(__dirname, 'format-folder-test'))
await createFolder(path.join(__dirname, 'subfolder'))
await createFile(
path.join(
__dirname,
'format-folder-test',
'subfolder',
'format-folder-test.sas'
),
content
)
const result = await formatFolder(
path.join(__dirname, 'format-folder-test')
)
const formattedContent = await readFile(
path.join(
__dirname,
'format-folder-test',
'subfolder',
'format-folder-test.sas'
)
)
expect(result).toEqual(expectedResult)
expect(formattedContent).toEqual(expectedContent)
await deleteFolder(path.join(__dirname, 'format-folder-test'))
})
it('should not update any files when there are no violations', async () => {
const content = `/**\n @file\n @brief <Your brief here>\n <h4> SAS Macros </h4>\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;`
const expectedResult = {
updatedFilePaths: [],
fixedDiagnosticsCount: 0,
unfixedDiagnostics: new Map<string, Diagnostic[]>([
[
path.join(__dirname, 'format-folder-test', 'format-folder-test.sas'),
[]
]
])
}
await createFolder(path.join(__dirname, 'format-folder-test'))
await createFile(
path.join(__dirname, 'format-folder-test', 'format-folder-test.sas'),
content
)
const result = await formatFolder(
path.join(__dirname, 'format-folder-test')
)
const formattedContent = await readFile(
path.join(__dirname, 'format-folder-test', 'format-folder-test.sas')
)
expect(formattedContent).toEqual(content)
expect(result).toEqual(expectedResult)
await deleteFolder(path.join(__dirname, 'format-folder-test'))
})
})

View File

@@ -0,0 +1,74 @@
import { listSubFoldersInFolder } from '@sasjs/utils/file'
import path from 'path'
import { lintFolder } from '../lint'
import { FormatResult } from '../types'
import { LintConfig } from '../types/LintConfig'
import { asyncForEach } from '../utils/asyncForEach'
import { getLintConfig } from '../utils/getLintConfig'
import { listSasFiles } from '../utils/listSasFiles'
import { formatFile } from './formatFile'
const excludeFolders = [
'.git',
'.github',
'.vscode',
'node_modules',
'sasjsbuild',
'sasjsresults'
]
/**
* Automatically formats all SAS files in the folder at the given path.
* @param {string} folderPath - the path to the folder to be formatted.
* @param {LintConfig} configuration - an optional configuration. When not passed in, this is read from the .sasjslint file.
* @returns {Promise<FormatResult>} Resolves successfully when all SAS files in the given folder have been formatted.
*/
export const formatFolder = async (
folderPath: string,
configuration?: LintConfig
): Promise<FormatResult> => {
const config = configuration || (await getLintConfig())
const diagnosticsBeforeFormat = await lintFolder(folderPath)
const diagnosticsCountBeforeFormat = Array.from(
diagnosticsBeforeFormat.values()
).reduce((a, b) => a + b.length, 0)
const fileNames = await listSasFiles(folderPath)
await asyncForEach(fileNames, async (fileName) => {
const filePath = path.join(folderPath, fileName)
await formatFile(filePath)
})
const subFolders = (await listSubFoldersInFolder(folderPath)).filter(
(f: string) => !excludeFolders.includes(f)
)
await asyncForEach(subFolders, async (subFolder) => {
await formatFolder(path.join(folderPath, subFolder), config)
})
const diagnosticsAfterFormat = await lintFolder(folderPath)
const diagnosticsCountAfterFormat = Array.from(
diagnosticsAfterFormat.values()
).reduce((a, b) => a + b.length, 0)
const fixedDiagnosticsCount =
diagnosticsCountBeforeFormat - diagnosticsCountAfterFormat
const updatedFilePaths: string[] = []
Array.from(diagnosticsBeforeFormat.keys()).forEach((filePath) => {
const diagnosticsBefore = diagnosticsBeforeFormat.get(filePath) || []
const diagnosticsAfter = diagnosticsAfterFormat.get(filePath) || []
if (diagnosticsBefore.length !== diagnosticsAfter.length) {
updatedFilePaths.push(filePath)
}
})
return {
updatedFilePaths,
fixedDiagnosticsCount,
unfixedDiagnostics: diagnosticsAfterFormat
}
}

View File

@@ -0,0 +1,51 @@
import { formatProject } from './formatProject'
import path from 'path'
import {
createFile,
createFolder,
deleteFolder,
readFile
} from '@sasjs/utils/file'
import { DefaultLintConfiguration } from '../utils'
import * as getProjectRootModule from '../utils/getProjectRoot'
jest.mock('../utils/getProjectRoot')
describe('formatProject', () => {
it('should format files in the current project', async () => {
const content = `%macro somemacro(); \n%put 'hello';\n%mend;`
const expectedContent = `/**\n @file\n @brief <Your brief here>\n <h4> SAS Macros </h4>\n**/\n%macro somemacro();\n%put 'hello';\n%mend somemacro;`
await createFolder(path.join(__dirname, 'format-project-test'))
await createFile(
path.join(__dirname, 'format-project-test', 'format-project-test.sas'),
content
)
await createFile(
path.join(__dirname, 'format-project-test', '.sasjslint'),
JSON.stringify(DefaultLintConfiguration)
)
jest
.spyOn(getProjectRootModule, 'getProjectRoot')
.mockImplementation(() =>
Promise.resolve(path.join(__dirname, 'format-project-test'))
)
await formatProject()
const result = await readFile(
path.join(__dirname, 'format-project-test', 'format-project-test.sas')
)
expect(result).toEqual(expectedContent)
await deleteFolder(path.join(__dirname, 'format-project-test'))
})
it('should throw an error when a project root is not found', async () => {
jest
.spyOn(getProjectRootModule, 'getProjectRoot')
.mockImplementationOnce(() => Promise.resolve(''))
await expect(formatProject()).rejects.toThrowError(
'SASjs Project Root was not found.'
)
})
})

View File

@@ -0,0 +1,18 @@
import { lintFolder } from '../lint/lintFolder'
import { FormatResult } from '../types/FormatResult'
import { getProjectRoot } from '../utils/getProjectRoot'
import { formatFolder } from './formatFolder'
/**
* Automatically formats all SAS files in the current project.
* @returns {Promise<FormatResult>} Resolves successfully when all SAS files in the current project have been formatted.
*/
export const formatProject = async (): Promise<FormatResult> => {
const projectRoot =
(await getProjectRoot()) || process.projectDir || process.currentDir
if (!projectRoot) {
throw new Error('SASjs Project Root was not found.')
}
return await formatFolder(projectRoot)
}

View File

@@ -0,0 +1,49 @@
import { formatText } from './formatText'
import * as getLintConfigModule from '../utils/getLintConfig'
import { LintConfig } from '../types'
jest.mock('../utils/getLintConfig')
describe('formatText', () => {
it('should format the given text based on configured rules', async () => {
jest
.spyOn(getLintConfigModule, 'getLintConfig')
.mockImplementationOnce(() =>
Promise.resolve(
new LintConfig(getLintConfigModule.DefaultLintConfiguration)
)
)
const text = `%macro test
%put 'hello';\r\n%mend; `
const expectedOutput = `/**
@file
@brief <Your brief here>
<h4> SAS Macros </h4>
**/\n%macro test
%put 'hello';\n%mend test;`
const output = await formatText(text)
expect(output).toEqual(expectedOutput)
})
it('should use CRLF line endings when configured', async () => {
jest
.spyOn(getLintConfigModule, 'getLintConfig')
.mockImplementationOnce(() =>
Promise.resolve(
new LintConfig({
...getLintConfigModule.DefaultLintConfiguration,
lineEndings: 'crlf'
})
)
)
const text = `%macro test\n %put 'hello';\r\n%mend; `
const expectedOutput = `/**\r\n @file\r\n @brief <Your brief here>\r\n <h4> SAS Macros </h4>\r\n**/\r\n%macro test\r\n %put 'hello';\r\n%mend test;`
const output = await formatText(text)
expect(output).toEqual(expectedOutput)
})
})

7
src/format/formatText.ts Normal file
View File

@@ -0,0 +1,7 @@
import { getLintConfig } from '../utils'
import { processText } from './shared'
export const formatText = async (text: string) => {
const config = await getLintConfig()
return processText(text, config)
}

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

@@ -0,0 +1,4 @@
export * from './formatText'
export * from './formatFile'
export * from './formatFolder'
export * from './formatProject'

37
src/format/shared.ts Normal file
View File

@@ -0,0 +1,37 @@
import { LintConfig } from '../types'
import { LineEndings } from '../types/LineEndings'
import { splitText } from '../utils/splitText'
export const processText = (text: string, config: LintConfig) => {
const processedText = processContent(config, text)
const lines = splitText(processedText, config)
const formattedLines = lines.map((line) => {
return processLine(config, line)
})
const configuredLineEnding =
config.lineEndings === LineEndings.LF ? '\n' : '\r\n'
return formattedLines.join(configuredLineEnding)
}
const processContent = (config: LintConfig, content: string): string => {
let processedContent = content
config.fileLintRules
.filter((r) => !!r.fix)
.forEach((rule) => {
processedContent = rule.fix!(processedContent)
})
return processedContent
}
export const processLine = (config: LintConfig, line: string): string => {
let processedLine = line
config.lineLintRules
.filter((r) => !!r.fix)
.forEach((rule) => {
processedLine = rule.fix!(line)
})
return processedLine
}

21
src/formatExample.ts Normal file
View File

@@ -0,0 +1,21 @@
import { formatText } from './format/formatText'
import { lintText } from './lint'
const content = `%put 'Hello';
%put 'World';
%macro somemacro()
%put 'test';
%mend;\r\n`
console.log(content)
lintText(content).then((diagnostics) => {
console.log('Before Formatting:')
console.table(diagnostics)
formatText(content).then((formattedText) => {
lintText(formattedText).then((newDiagnostics) => {
console.log('After Formatting:')
console.log(formattedText)
console.table(newDiagnostics)
})
})
})

View File

@@ -1,3 +1,4 @@
export * from './format'
export * from './lint' export * from './lint'
export * from './types' export * from './types'
export * from './utils' export * from './utils'

View File

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

View File

@@ -1,71 +1,135 @@
import { lintFolder } from './lintFolder' import { lintFolder } from './lintFolder'
import { Severity } from '../types/Severity' import { Severity } from '../types/Severity'
import path from 'path' import path from 'path'
import {
createFile,
createFolder,
deleteFolder,
readFile
} from '@sasjs/utils/file'
const expectedFilesCount = 1
const expectedDiagnostics = [
{
message: 'Line contains trailing spaces',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 2,
severity: Severity.Warning
},
{
message: 'Line contains trailing spaces',
lineNumber: 2,
startColumnNumber: 1,
endColumnNumber: 2,
severity: Severity.Warning
},
{
message: 'File name contains spaces',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
},
{
message: 'File name contains uppercase characters',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
},
{
message: 'File missing Doxygen header',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
},
{
message: 'Line contains encoded password',
lineNumber: 5,
startColumnNumber: 10,
endColumnNumber: 18,
severity: Severity.Error
},
{
message: 'Line is indented with a tab',
lineNumber: 7,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
},
{
message: 'Line has incorrect indentation - 3 spaces',
lineNumber: 6,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
},
{
message: '%mend statement is missing macro name - mf_getuniquelibref',
lineNumber: 17,
startColumnNumber: 3,
endColumnNumber: 9,
severity: Severity.Warning
}
]
describe('lintFolder', () => { describe('lintFolder', () => {
it('should identify lint issues in a given folder', async () => { it('should identify lint issues in a given folder', async () => {
const results = await lintFolder(path.join(__dirname, '..')) await createFolder(path.join(__dirname, 'lint-folder-test'))
const content = await readFile(
expect(results.size).toEqual(1)
const diagnostics = results.get(
path.join(__dirname, '..', 'Example File.sas') path.join(__dirname, '..', 'Example File.sas')
)
await createFile(
path.join(__dirname, 'lint-folder-test', 'Example File.sas'),
content
)
const results = await lintFolder(path.join(__dirname, 'lint-folder-test'))
expect(results.size).toEqual(expectedFilesCount)
const diagnostics = results.get(
path.join(__dirname, 'lint-folder-test', 'Example File.sas')
)! )!
expect(diagnostics.length).toEqual(8) expect(diagnostics.length).toEqual(expectedDiagnostics.length)
expect(diagnostics).toContainEqual({ expect(diagnostics).toContainEqual(expectedDiagnostics[0])
message: 'Line contains trailing spaces', expect(diagnostics).toContainEqual(expectedDiagnostics[1])
lineNumber: 1, expect(diagnostics).toContainEqual(expectedDiagnostics[2])
startColumnNumber: 1, expect(diagnostics).toContainEqual(expectedDiagnostics[3])
endColumnNumber: 2, expect(diagnostics).toContainEqual(expectedDiagnostics[4])
severity: Severity.Warning expect(diagnostics).toContainEqual(expectedDiagnostics[5])
}) expect(diagnostics).toContainEqual(expectedDiagnostics[6])
expect(diagnostics).toContainEqual({ expect(diagnostics).toContainEqual(expectedDiagnostics[7])
message: 'Line contains trailing spaces', expect(diagnostics).toContainEqual(expectedDiagnostics[8])
lineNumber: 2,
startColumnNumber: 1, await deleteFolder(path.join(__dirname, 'lint-folder-test'))
endColumnNumber: 2, })
severity: Severity.Warning
}) it('should identify lint issues in subfolders of a given folder', async () => {
expect(diagnostics).toContainEqual({ await createFolder(path.join(__dirname, 'lint-folder-test'))
message: 'File name contains spaces', await createFolder(path.join(__dirname, 'lint-folder-test', 'subfolder'))
lineNumber: 1, const content = await readFile(
startColumnNumber: 1, path.join(__dirname, '..', 'Example File.sas')
endColumnNumber: 1, )
severity: Severity.Warning await createFile(
}) path.join(__dirname, 'lint-folder-test', 'subfolder', 'Example File.sas'),
expect(diagnostics).toContainEqual({ content
message: 'File name contains uppercase characters', )
lineNumber: 1, const results = await lintFolder(path.join(__dirname, 'lint-folder-test'))
startColumnNumber: 1, expect(results.size).toEqual(expectedFilesCount)
endColumnNumber: 1, const diagnostics = results.get(
severity: Severity.Warning path.join(__dirname, 'lint-folder-test', 'subfolder', 'Example File.sas')
}) )!
expect(diagnostics).toContainEqual({ expect(diagnostics.length).toEqual(expectedDiagnostics.length)
message: 'File missing Doxygen header', expect(diagnostics).toContainEqual(expectedDiagnostics[0])
lineNumber: 1, expect(diagnostics).toContainEqual(expectedDiagnostics[1])
startColumnNumber: 1, expect(diagnostics).toContainEqual(expectedDiagnostics[2])
endColumnNumber: 1, expect(diagnostics).toContainEqual(expectedDiagnostics[3])
severity: Severity.Warning expect(diagnostics).toContainEqual(expectedDiagnostics[4])
}) expect(diagnostics).toContainEqual(expectedDiagnostics[5])
expect(diagnostics).toContainEqual({ expect(diagnostics).toContainEqual(expectedDiagnostics[6])
message: 'Line contains encoded password', expect(diagnostics).toContainEqual(expectedDiagnostics[7])
lineNumber: 5, expect(diagnostics).toContainEqual(expectedDiagnostics[8])
startColumnNumber: 10,
endColumnNumber: 18, await deleteFolder(path.join(__dirname, 'lint-folder-test'))
severity: Severity.Error
})
expect(diagnostics).toContainEqual({
message: 'Line is indented with a tab',
lineNumber: 7,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(diagnostics).toContainEqual({
message: 'Line has incorrect indentation - 3 spaces',
lineNumber: 6,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
}) })
}) })

View File

@@ -1,82 +1,121 @@
import { lintProject } from './lintProject' import { lintProject } from './lintProject'
import { Severity } from '../types/Severity' import { Severity } from '../types/Severity'
import * as utils from '../utils' import * as getProjectRootModule from '../utils/getProjectRoot'
import path from 'path' import path from 'path'
jest.mock('../utils') import { createFolder, createFile, readFile, deleteFolder } from '@sasjs/utils'
import { DefaultLintConfiguration } from '../utils'
jest.mock('../utils/getProjectRoot')
const expectedFilesCount = 1
const expectedDiagnostics = [
{
message: 'Line contains trailing spaces',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 2,
severity: Severity.Warning
},
{
message: 'Line contains trailing spaces',
lineNumber: 2,
startColumnNumber: 1,
endColumnNumber: 2,
severity: Severity.Warning
},
{
message: 'File name contains spaces',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
},
{
message: 'File name contains uppercase characters',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
},
{
message: 'File missing Doxygen header',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
},
{
message: 'Line contains encoded password',
lineNumber: 5,
startColumnNumber: 10,
endColumnNumber: 18,
severity: Severity.Error
},
{
message: 'Line is indented with a tab',
lineNumber: 7,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
},
{
message: 'Line has incorrect indentation - 3 spaces',
lineNumber: 6,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
},
{
message: '%mend statement is missing macro name - mf_getuniquelibref',
lineNumber: 17,
startColumnNumber: 3,
endColumnNumber: 9,
severity: Severity.Warning
}
]
describe('lintProject', () => { describe('lintProject', () => {
it('should identify lint issues in a given project', async () => { it('should identify lint issues in a given project', async () => {
await createFolder(path.join(__dirname, 'lint-project-test'))
const content = await readFile(
path.join(__dirname, '..', 'Example File.sas')
)
await createFile(
path.join(__dirname, 'lint-project-test', 'Example File.sas'),
content
)
await createFile(
path.join(__dirname, 'lint-project-test', '.sasjslint'),
JSON.stringify(DefaultLintConfiguration)
)
jest jest
.spyOn(utils, 'getProjectRoot') .spyOn(getProjectRootModule, 'getProjectRoot')
.mockImplementationOnce(() => Promise.resolve(path.join(__dirname, '..'))) .mockImplementation(() =>
Promise.resolve(path.join(__dirname, 'lint-project-test'))
)
const results = await lintProject() const results = await lintProject()
expect(results.size).toEqual(1) expect(results.size).toEqual(expectedFilesCount)
const diagnostics = results.get( const diagnostics = results.get(
path.join(__dirname, '..', 'Example File.sas') path.join(__dirname, 'lint-project-test', 'Example File.sas')
)! )!
expect(diagnostics.length).toEqual(8) expect(diagnostics.length).toEqual(expectedDiagnostics.length)
expect(diagnostics).toContainEqual({ expect(diagnostics).toContainEqual(expectedDiagnostics[0])
message: 'Line contains trailing spaces', expect(diagnostics).toContainEqual(expectedDiagnostics[1])
lineNumber: 1, expect(diagnostics).toContainEqual(expectedDiagnostics[2])
startColumnNumber: 1, expect(diagnostics).toContainEqual(expectedDiagnostics[3])
endColumnNumber: 2, expect(diagnostics).toContainEqual(expectedDiagnostics[4])
severity: Severity.Warning expect(diagnostics).toContainEqual(expectedDiagnostics[5])
}) expect(diagnostics).toContainEqual(expectedDiagnostics[6])
expect(diagnostics).toContainEqual({ expect(diagnostics).toContainEqual(expectedDiagnostics[7])
message: 'Line contains trailing spaces', expect(diagnostics).toContainEqual(expectedDiagnostics[8])
lineNumber: 2,
startColumnNumber: 1, await deleteFolder(path.join(__dirname, 'lint-project-test'))
endColumnNumber: 2,
severity: Severity.Warning
})
expect(diagnostics).toContainEqual({
message: 'File name contains spaces',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(diagnostics).toContainEqual({
message: 'File name contains uppercase characters',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(diagnostics).toContainEqual({
message: 'File missing Doxygen header',
lineNumber: 1,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(diagnostics).toContainEqual({
message: 'Line contains encoded password',
lineNumber: 5,
startColumnNumber: 10,
endColumnNumber: 18,
severity: Severity.Error
})
expect(diagnostics).toContainEqual({
message: 'Line is indented with a tab',
lineNumber: 7,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
expect(diagnostics).toContainEqual({
message: 'Line has incorrect indentation - 3 spaces',
lineNumber: 6,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
}) })
it('should throw an error when a project root is not found', async () => { it('should throw an error when a project root is not found', async () => {
jest jest
.spyOn(utils, 'getProjectRoot') .spyOn(getProjectRootModule, 'getProjectRoot')
.mockImplementationOnce(() => Promise.resolve('')) .mockImplementationOnce(() => Promise.resolve(''))
await expect(lintProject()).rejects.toThrowError( await expect(lintProject()).rejects.toThrowError(

View File

@@ -1,4 +1,4 @@
import { getProjectRoot } from '../utils' import { getProjectRoot } from '../utils/getProjectRoot'
import { lintFolder } from './lintFolder' import { lintFolder } from './lintFolder'
/** /**
@@ -8,7 +8,6 @@ import { lintFolder } from './lintFolder'
export const lintProject = async () => { export const lintProject = async () => {
const projectRoot = const projectRoot =
(await getProjectRoot()) || process.projectDir || process.currentDir (await getProjectRoot()) || process.projectDir || process.currentDir
if (!projectRoot) { if (!projectRoot) {
throw new Error('SASjs Project Root was not found.') throw new Error('SASjs Project Root was not found.')
} }

View File

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

View File

@@ -1,17 +1,8 @@
import { LintConfig, Diagnostic } from '../types' import { LintConfig, Diagnostic } from '../types'
import { splitText } from '../utils'
/**
* 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) => { export const processText = (text: string, config: LintConfig) => {
const lines = splitText(text) const lines = splitText(text, config)
const diagnostics: Diagnostic[] = [] const diagnostics: Diagnostic[] = []
diagnostics.push(...processContent(config, text)) diagnostics.push(...processContent(config, text))
lines.forEach((line, index) => { lines.forEach((line, index) => {

View File

@@ -1,7 +1,8 @@
import { Severity } from '../types/Severity' import { LintConfig } from '../../types'
import { Severity } from '../../types/Severity'
import { hasDoxygenHeader } from './hasDoxygenHeader' import { hasDoxygenHeader } from './hasDoxygenHeader'
describe('hasDoxygenHeader', () => { describe('hasDoxygenHeader - test', () => {
it('should return an empty array when the file starts with a doxygen header', () => { it('should return an empty array when the file starts with a doxygen header', () => {
const content = `/** const content = `/**
@file @file
@@ -58,7 +59,7 @@ describe('hasDoxygenHeader', () => {
it('should return an array with a single diagnostic when the file is undefined', () => { it('should return an array with a single diagnostic when the file is undefined', () => {
const content = undefined const content = undefined
expect(hasDoxygenHeader.test((content as unknown) as string)).toEqual([ expect(hasDoxygenHeader.test(content as unknown as string)).toEqual([
{ {
message: 'File missing Doxygen header', message: 'File missing Doxygen header',
lineNumber: 1, lineNumber: 1,
@@ -69,3 +70,47 @@ describe('hasDoxygenHeader', () => {
]) ])
}) })
}) })
describe('hasDoxygenHeader - fix', () => {
it('should not alter the text if a doxygen header is already present', () => {
const content = `/**
@file
@brief Returns an unused libref
**/
%macro mf_getuniquelibref(prefix=mclib,maxtries=1000);
%local x libref;
%let x={SAS002};
%do x=0 %to &maxtries;`
expect(hasDoxygenHeader.fix!(content)).toEqual(content)
})
it('should should add a doxygen header if not present', () => {
const content = `%macro mf_getuniquelibref(prefix=mclib,maxtries=1000);
%local x libref;
%let x={SAS002};
%do x=0 %to &maxtries;`
expect(hasDoxygenHeader.fix!(content)).toEqual(
`/**
@file
@brief <Your brief here>
<h4> SAS Macros </h4>
**/` +
'\n' +
content
)
})
it('should use CRLF line endings when configured', () => {
const content = `%macro mf_getuniquelibref(prefix=mclib,maxtries=1000);\n%local x libref;\n%let x={SAS002};\n%do x=0 %to &maxtries;`
const config = new LintConfig({ lineEndings: 'crlf' })
expect(hasDoxygenHeader.fix!(content, config)).toEqual(
`/**\r\n @file\r\n @brief <Your brief here>\r\n <h4> SAS Macros </h4>\r\n**/` +
'\r\n' +
content
)
})
})

View File

@@ -1,6 +1,10 @@
import { FileLintRule } from '../types/LintRule' import { LintConfig } from '../../types'
import { LintRuleType } from '../types/LintRuleType' import { LineEndings } from '../../types/LineEndings'
import { Severity } from '../types/Severity' import { FileLintRule } from '../../types/LintRule'
import { LintRuleType } from '../../types/LintRuleType'
import { Severity } from '../../types/Severity'
const DoxygenHeader = `/**{lineEnding} @file{lineEnding} @brief <Your brief here>{lineEnding} <h4> SAS Macros </h4>{lineEnding}**/`
const name = 'hasDoxygenHeader' const name = 'hasDoxygenHeader'
const description = const description =
@@ -32,6 +36,19 @@ const test = (value: string) => {
} }
} }
const fix = (value: string, config?: LintConfig): string => {
if (test(value).length === 0) {
return value
}
const lineEndingConfig = config?.lineEndings || LineEndings.LF
const lineEnding = lineEndingConfig === LineEndings.LF ? '\n' : '\r\n'
return `${DoxygenHeader.replace(
/{lineEnding}/g,
lineEnding
)}${lineEnding}${value}`
}
/** /**
* Lint rule that checks for the presence of a Doxygen header in a given file. * Lint rule that checks for the presence of a Doxygen header in a given file.
*/ */
@@ -40,5 +57,6 @@ export const hasDoxygenHeader: FileLintRule = {
name, name,
description, description,
message, message,
test test,
fix
} }

View File

@@ -1,7 +1,8 @@
import { Severity } from '../types/Severity' import { LintConfig } from '../../types'
import { Severity } from '../../types/Severity'
import { hasMacroNameInMend } from './hasMacroNameInMend' import { hasMacroNameInMend } from './hasMacroNameInMend'
describe('hasMacroNameInMend', () => { describe('hasMacroNameInMend - test', () => {
it('should return an empty array when %mend has correct macro name', () => { it('should return an empty array when %mend has correct macro name', () => {
const content = ` const content = `
%macro somemacro(); %macro somemacro();
@@ -113,7 +114,7 @@ describe('hasMacroNameInMend', () => {
it('should return an empty array when the file is undefined', () => { it('should return an empty array when the file is undefined', () => {
const content = undefined const content = undefined
expect(hasMacroNameInMend.test((content as unknown) as string)).toEqual([]) expect(hasMacroNameInMend.test(content as unknown as string)).toEqual([])
}) })
describe('nestedMacros', () => { describe('nestedMacros', () => {
@@ -319,4 +320,146 @@ describe('hasMacroNameInMend', () => {
}) })
}) })
}) })
it('should use the configured line ending while testing content', () => {
const content = `%macro somemacro();\r\n%put &sysmacroname;\r\n%mend;`
const diagnostics = hasMacroNameInMend.test(
content,
new LintConfig({ lineEndings: 'crlf' })
)
expect(diagnostics).toEqual([
{
message: '%mend statement is missing macro name - somemacro',
lineNumber: 3,
startColumnNumber: 1,
endColumnNumber: 7,
severity: Severity.Warning
}
])
})
})
describe('hasMacroNameInMend - fix', () => {
it('should add macro name to the mend statement if not present', () => {
const content = ` %macro somemacro;\n %put &sysmacroname;\n %mend;`
const expectedContent = ` %macro somemacro;\n %put &sysmacroname;\n %mend somemacro;`
const formattedContent = hasMacroNameInMend.fix!(content, new LintConfig())
expect(formattedContent).toEqual(expectedContent)
})
it('should add macro name to the mend statement if not present ( code in single line )', () => {
const content = `%macro somemacro; %put &sysmacroname; %mend; some code;`
const expectedContent = `%macro somemacro; %put &sysmacroname; %mend somemacro; some code;`
const formattedContent = hasMacroNameInMend.fix!(content, new LintConfig())
expect(formattedContent).toEqual(expectedContent)
})
it('should add macro name to the mend statement if not present ( with multiple macros )', () => {
const content = `
%macro somemacro;
%put &sysmacroname;
%mend somemacro;
%macro somemacro2;
%put &sysmacroname2;
%mend;`
const expectedContent = `
%macro somemacro;
%put &sysmacroname;
%mend somemacro;
%macro somemacro2;
%put &sysmacroname2;
%mend somemacro2;`
const formattedContent = hasMacroNameInMend.fix!(content, new LintConfig())
expect(formattedContent).toEqual(expectedContent)
})
it('should remove redundant %mend statement', () => {
const content = `
%macro somemacro;
%put &sysmacroname;
%mend somemacro;
%mend something;`
const expectedContent = `
%macro somemacro;
%put &sysmacroname;
%mend somemacro;
`
const formattedContent = hasMacroNameInMend.fix!(content, new LintConfig())
expect(formattedContent).toEqual(expectedContent)
})
it('should remove redundant %mend statement with comments', () => {
const content = `
%macro somemacro;
%put &sysmacroname;
%mend somemacro;
/* some comment */
/* some comment */ %mend something; some code;
/* some comment */`
const expectedContent = `
%macro somemacro;
%put &sysmacroname;
%mend somemacro;
/* some comment */
/* some comment */ some code;
/* some comment */`
const formattedContent = hasMacroNameInMend.fix!(content, new LintConfig())
expect(formattedContent).toEqual(expectedContent)
})
it('should correct mismatched macro name', () => {
const content = `
%macro somemacro;
%put &sysmacroname;
%mend someanothermacro;`
const expectedContent = `
%macro somemacro;
%put &sysmacroname;
%mend somemacro;`
const formattedContent = hasMacroNameInMend.fix!(content, new LintConfig())
expect(formattedContent).toEqual(expectedContent)
})
it('should correct mismatched macro name with comments', () => {
const content = `
%macro somemacro;
/* some comments */
%put &sysmacroname;
/* some comments */
%mend someanothermacro ;`
const expectedContent = `
%macro somemacro;
/* some comments */
%put &sysmacroname;
/* some comments */
%mend somemacro ;`
const formattedContent = hasMacroNameInMend.fix!(content, new LintConfig())
expect(formattedContent).toEqual(expectedContent)
})
it('should use the configured line ending while applying the fix', () => {
const content = `%macro somemacro();\r\n%put &sysmacroname;\r\n%mend ;`
const expectedContent = `%macro somemacro();\r\n%put &sysmacroname;\r\n%mend somemacro ;`
const formattedContent = hasMacroNameInMend.fix!(
content,
new LintConfig({ lineEndings: 'crlf' })
)
expect(formattedContent).toEqual(expectedContent)
})
}) })

View File

@@ -0,0 +1,132 @@
import { Diagnostic } from '../../types/Diagnostic'
import { FileLintRule } from '../../types/LintRule'
import { LintRuleType } from '../../types/LintRuleType'
import { Severity } from '../../types/Severity'
import { getColumnNumber } from '../../utils/getColumnNumber'
import { LintConfig } from '../../types'
import { LineEndings } from '../../types/LineEndings'
import { parseMacros } from '../../utils/parseMacros'
const name = 'hasMacroNameInMend'
const description =
'Enforces the presence of the macro name in each %mend statement.'
const message = '%mend statement has missing or incorrect macro name'
const test = (value: string, config?: LintConfig) => {
const lineEnding = config?.lineEndings === LineEndings.CRLF ? '\r\n' : '\n'
const lines: string[] = value ? value.split(lineEnding) : []
const macros = parseMacros(value, config)
const diagnostics: Diagnostic[] = []
macros.forEach((macro) => {
if (macro.startLineNumber === null && macro.endLineNumber !== null) {
const endLine = lines[macro.endLineNumber - 1]
diagnostics.push({
message: `%mend statement is redundant`,
lineNumber: macro.endLineNumber,
startColumnNumber: getColumnNumber(endLine, '%mend'),
endColumnNumber:
getColumnNumber(endLine, '%mend') + macro.termination.length,
severity: Severity.Warning
})
} else if (macro.endLineNumber === null && macro.startLineNumber !== null) {
diagnostics.push({
message: `Missing %mend statement for macro - ${macro.name}`,
lineNumber: macro.startLineNumber,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
} else if (macro.mismatchedMendMacroName) {
const endLine = lines[(macro.endLineNumber as number) - 1]
diagnostics.push({
message: `%mend statement has mismatched macro name, it should be '${
macro!.name
}'`,
lineNumber: macro.endLineNumber as number,
startColumnNumber: getColumnNumber(
endLine,
macro.mismatchedMendMacroName
),
endColumnNumber:
getColumnNumber(endLine, macro.mismatchedMendMacroName) +
macro.mismatchedMendMacroName.length -
1,
severity: Severity.Warning
})
} else if (!macro.hasMacroNameInMend) {
const endLine = lines[(macro.endLineNumber as number) - 1]
diagnostics.push({
message: `%mend statement is missing macro name - ${macro.name}`,
lineNumber: macro.endLineNumber as number,
startColumnNumber: getColumnNumber(endLine, '%mend'),
endColumnNumber: getColumnNumber(endLine, '%mend') + 6,
severity: Severity.Warning
})
}
})
return diagnostics
}
const fix = (value: string, config?: LintConfig): string => {
const lineEnding = config?.lineEndings === LineEndings.CRLF ? '\r\n' : '\n'
const lines: string[] = value ? value.split(lineEnding) : []
const macros = parseMacros(value, config)
macros.forEach((macro) => {
if (macro.startLineNumber === null && macro.endLineNumber !== null) {
// %mend statement is redundant
const endLine = lines[macro.endLineNumber - 1]
const startColumnNumber = getColumnNumber(endLine, '%mend')
const endColumnNumber =
getColumnNumber(endLine, '%mend') + macro.termination.length
const beforeStatement = endLine.slice(0, startColumnNumber - 1)
const afterStatement = endLine.slice(endColumnNumber)
lines[macro.endLineNumber - 1] = beforeStatement + afterStatement
} else if (macro.endLineNumber === null && macro.startLineNumber !== null) {
// missing %mend statement
} else if (macro.mismatchedMendMacroName) {
// mismatched macro name
const endLine = lines[(macro.endLineNumber as number) - 1]
const startColumnNumber = getColumnNumber(
endLine,
macro.mismatchedMendMacroName
)
const endColumnNumber =
getColumnNumber(endLine, macro.mismatchedMendMacroName) +
macro.mismatchedMendMacroName.length -
1
const beforeMacroName = endLine.slice(0, startColumnNumber - 1)
const afterMacroName = endLine.slice(endColumnNumber)
lines[(macro.endLineNumber as number) - 1] =
beforeMacroName + macro.name + afterMacroName
} else if (!macro.hasMacroNameInMend) {
// %mend statement is missing macro name
const endLine = lines[(macro.endLineNumber as number) - 1]
const startColumnNumber = getColumnNumber(endLine, '%mend')
const endColumnNumber = getColumnNumber(endLine, '%mend') + 4
const beforeStatement = endLine.slice(0, startColumnNumber - 1)
const afterStatement = endLine.slice(endColumnNumber)
lines[(macro.endLineNumber as number) - 1] =
beforeStatement + `%mend ${macro.name}` + afterStatement
}
})
const formattedText = lines.join(lineEnding)
return formattedText
}
/**
* Lint rule that checks for the presence of macro name in %mend statement.
*/
export const hasMacroNameInMend: FileLintRule = {
type: LintRuleType.File,
name,
description,
message,
test,
fix
}

View File

@@ -1,4 +1,4 @@
import { Severity } from '../types/Severity' import { Severity } from '../../types/Severity'
import { hasMacroParentheses } from './hasMacroParentheses' import { hasMacroParentheses } from './hasMacroParentheses'
describe('hasMacroParentheses', () => { describe('hasMacroParentheses', () => {
@@ -16,7 +16,6 @@ describe('hasMacroParentheses', () => {
%macro somemacro; %macro somemacro;
%put &sysmacroname; %put &sysmacroname;
%mend somemacro;` %mend somemacro;`
expect(hasMacroParentheses.test(content)).toEqual([ expect(hasMacroParentheses.test(content)).toEqual([
{ {
message: 'Macro definition missing parentheses', message: 'Macro definition missing parentheses',
@@ -28,7 +27,7 @@ describe('hasMacroParentheses', () => {
]) ])
}) })
it('should return an array with a single diagnostics when macro defined without name', () => { it('should return an array with a single diagnostic when macro defined without name', () => {
const content = ` const content = `
%macro (); %macro ();
%put &sysmacroname; %put &sysmacroname;
@@ -45,7 +44,22 @@ describe('hasMacroParentheses', () => {
]) ])
}) })
it('should return an array with a single diagnostics when macro defined without name and parentheses', () => { it('should return an array with a single diagnostic when macro defined without name ( single line code )', () => {
const content = `
%macro (); %put &sysmacroname; %mend;`
expect(hasMacroParentheses.test(content)).toEqual([
{
message: 'Macro definition missing name',
lineNumber: 2,
startColumnNumber: 3,
endColumnNumber: 12,
severity: Severity.Warning
}
])
})
it('should return an array with a single diagnostic when macro defined without name and parentheses', () => {
const content = ` const content = `
%macro ; %macro ;
%put &sysmacroname; %put &sysmacroname;
@@ -65,7 +79,7 @@ describe('hasMacroParentheses', () => {
it('should return an empty array when the file is undefined', () => { it('should return an empty array when the file is undefined', () => {
const content = undefined const content = undefined
expect(hasMacroParentheses.test((content as unknown) as string)).toEqual([]) expect(hasMacroParentheses.test(content as unknown as string)).toEqual([])
}) })
describe('with extra spaces and comments', () => { describe('with extra spaces and comments', () => {

View File

@@ -0,0 +1,64 @@
import { Diagnostic } from '../../types/Diagnostic'
import { FileLintRule } from '../../types/LintRule'
import { LintRuleType } from '../../types/LintRuleType'
import { Severity } from '../../types/Severity'
import { getColumnNumber } from '../../utils/getColumnNumber'
import { parseMacros } from '../../utils/parseMacros'
import { LintConfig } from '../../types'
const name = 'hasMacroParentheses'
const description = 'Enforces the presence of parentheses in macro definitions.'
const message = 'Macro definition missing parentheses'
const test = (value: string, config?: LintConfig) => {
const diagnostics: Diagnostic[] = []
const macros = parseMacros(value, config)
macros.forEach((macro) => {
if (!macro.name) {
diagnostics.push({
message: 'Macro definition missing name',
lineNumber: macro.startLineNumber!,
startColumnNumber: getColumnNumber(macro.declarationLine, '%macro'),
endColumnNumber:
getColumnNumber(macro.declarationLine, '%macro') +
macro.declaration.length,
severity: Severity.Warning
})
} else if (!macro.declarationLine.includes('(')) {
diagnostics.push({
message,
lineNumber: macro.startLineNumber!,
startColumnNumber: getColumnNumber(macro.declarationLine, macro.name),
endColumnNumber:
getColumnNumber(macro.declarationLine, macro.name) +
macro.name.length -
1,
severity: Severity.Warning
})
} else if (macro.name !== macro.name.trim()) {
diagnostics.push({
message: 'Macro definition contains space(s)',
lineNumber: macro.startLineNumber!,
startColumnNumber: getColumnNumber(macro.declarationLine, macro.name),
endColumnNumber:
getColumnNumber(macro.declarationLine, macro.name) +
macro.name.length -
1 +
`()`.length,
severity: Severity.Warning
})
}
})
return diagnostics
}
/**
* Lint rule that enforces the presence of parentheses in macro definitions..
*/
export const hasMacroParentheses: FileLintRule = {
type: LintRuleType.File,
name,
description,
message,
test
}

5
src/rules/file/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export { hasDoxygenHeader } from './hasDoxygenHeader'
export { hasMacroNameInMend } from './hasMacroNameInMend'
export { hasMacroParentheses } from './hasMacroParentheses'
export { lineEndings } from './lineEndings'
export { noNestedMacros } from './noNestedMacros'

View File

@@ -0,0 +1,141 @@
import { LintConfig, Severity } from '../../types'
import { LineEndings } from '../../types/LineEndings'
import { lineEndings } from './lineEndings'
describe('lineEndings - test', () => {
it('should return an empty array when the text contains the configured line endings', () => {
const text = "%put 'hello';\n%put 'world';\n"
const config = new LintConfig({ lineEndings: LineEndings.LF })
expect(lineEndings.test(text, config)).toEqual([])
})
it('should return an array with a single diagnostic when a line is terminated with a CRLF ending', () => {
const text = "%put 'hello';\n%put 'world';\r\n"
const config = new LintConfig({ lineEndings: LineEndings.LF })
expect(lineEndings.test(text, config)).toEqual([
{
message: 'Incorrect line ending - CRLF instead of LF',
lineNumber: 2,
startColumnNumber: 13,
endColumnNumber: 14,
severity: Severity.Warning
}
])
})
it('should return an array with a single diagnostic when a line is terminated with an LF ending', () => {
const text = "%put 'hello';\n%put 'world';\r\n"
const config = new LintConfig({ lineEndings: LineEndings.CRLF })
expect(lineEndings.test(text, config)).toEqual([
{
message: 'Incorrect line ending - LF instead of CRLF',
lineNumber: 1,
startColumnNumber: 13,
endColumnNumber: 14,
severity: Severity.Warning
}
])
})
it('should return an array with a diagnostic for each line terminated with an LF ending', () => {
const text = "%put 'hello';\n%put 'test';\r\n%put 'world';\n"
const config = new LintConfig({ lineEndings: LineEndings.CRLF })
expect(lineEndings.test(text, config)).toContainEqual({
message: 'Incorrect line ending - LF instead of CRLF',
lineNumber: 1,
startColumnNumber: 13,
endColumnNumber: 14,
severity: Severity.Warning
})
expect(lineEndings.test(text, config)).toContainEqual({
message: 'Incorrect line ending - LF instead of CRLF',
lineNumber: 3,
startColumnNumber: 13,
endColumnNumber: 14,
severity: Severity.Warning
})
})
it('should return an array with a diagnostic for each line terminated with a CRLF ending', () => {
const text = "%put 'hello';\r\n%put 'test';\n%put 'world';\r\n"
const config = new LintConfig({ lineEndings: LineEndings.LF })
expect(lineEndings.test(text, config)).toContainEqual({
message: 'Incorrect line ending - CRLF instead of LF',
lineNumber: 1,
startColumnNumber: 13,
endColumnNumber: 14,
severity: Severity.Warning
})
expect(lineEndings.test(text, config)).toContainEqual({
message: 'Incorrect line ending - CRLF instead of LF',
lineNumber: 3,
startColumnNumber: 13,
endColumnNumber: 14,
severity: Severity.Warning
})
})
it('should return an array with a diagnostic for lines terminated with a CRLF ending', () => {
const text =
"%put 'hello';\r\n%put 'test';\r\n%put 'world';\n%put 'test2';\n%put 'world2';\r\n"
const config = new LintConfig({ lineEndings: LineEndings.LF })
expect(lineEndings.test(text, config)).toContainEqual({
message: 'Incorrect line ending - CRLF instead of LF',
lineNumber: 1,
startColumnNumber: 13,
endColumnNumber: 14,
severity: Severity.Warning
})
expect(lineEndings.test(text, config)).toContainEqual({
message: 'Incorrect line ending - CRLF instead of LF',
lineNumber: 2,
startColumnNumber: 12,
endColumnNumber: 13,
severity: Severity.Warning
})
expect(lineEndings.test(text, config)).toContainEqual({
message: 'Incorrect line ending - CRLF instead of LF',
lineNumber: 5,
startColumnNumber: 14,
endColumnNumber: 15,
severity: Severity.Warning
})
})
})
describe('lineEndings - fix', () => {
it('should transform line endings to LF', () => {
const text =
"%put 'hello';\r\n%put 'test';\r\n%put 'world';\n%put 'test2';\n%put 'world2';\r\n"
const config = new LintConfig({ lineEndings: LineEndings.LF })
const formattedText = lineEndings.fix!(text, config)
expect(formattedText).toEqual(
"%put 'hello';\n%put 'test';\n%put 'world';\n%put 'test2';\n%put 'world2';\n"
)
})
it('should transform line endings to CRLF', () => {
const text =
"%put 'hello';\r\n%put 'test';\r\n%put 'world';\n%put 'test2';\n%put 'world2';\r\n"
const config = new LintConfig({ lineEndings: LineEndings.CRLF })
const formattedText = lineEndings.fix!(text, config)
expect(formattedText).toEqual(
"%put 'hello';\r\n%put 'test';\r\n%put 'world';\r\n%put 'test2';\r\n%put 'world2';\r\n"
)
})
it('should use LF line endings by default', () => {
const text =
"%put 'hello';\r\n%put 'test';\r\n%put 'world';\n%put 'test2';\n%put 'world2';\r\n"
const formattedText = lineEndings.fix!(text)
expect(formattedText).toEqual(
"%put 'hello';\n%put 'test';\n%put 'world';\n%put 'test2';\n%put 'world2';\n"
)
})
})

View File

@@ -0,0 +1,83 @@
import { Diagnostic, LintConfig } from '../../types'
import { LineEndings } from '../../types/LineEndings'
import { FileLintRule } from '../../types/LintRule'
import { LintRuleType } from '../../types/LintRuleType'
import { Severity } from '../../types/Severity'
const name = 'lineEndings'
const description = 'Ensures line endings conform to the configured type.'
const message = 'Incorrect line ending - {actual} instead of {expected}'
const test = (value: string, config?: LintConfig) => {
const lineEndingConfig = config?.lineEndings || LineEndings.LF
const expectedLineEnding =
lineEndingConfig === LineEndings.LF ? '{lf}' : '{crlf}'
const incorrectLineEnding = expectedLineEnding === '{lf}' ? '{crlf}' : '{lf}'
const lines = value
.replace(/\r\n/g, '{crlf}')
.replace(/\n/g, '{lf}')
.split(new RegExp(`(?<=${expectedLineEnding})`))
const diagnostics: Diagnostic[] = []
let indexOffset = 0
lines.forEach((line, index) => {
if (line.endsWith(incorrectLineEnding)) {
diagnostics.push({
message: message
.replace('{expected}', expectedLineEnding === '{lf}' ? 'LF' : 'CRLF')
.replace('{actual}', incorrectLineEnding === '{lf}' ? 'LF' : 'CRLF'),
lineNumber: index + 1 + indexOffset,
startColumnNumber: line.indexOf(incorrectLineEnding),
endColumnNumber: line.indexOf(incorrectLineEnding) + 1,
severity: Severity.Warning
})
} else {
const splitLine = line.split(new RegExp(`(?<=${incorrectLineEnding})`))
if (splitLine.length > 1) {
indexOffset += splitLine.length - 1
}
splitLine.forEach((l, i) => {
if (l.endsWith(incorrectLineEnding)) {
diagnostics.push({
message: message
.replace(
'{expected}',
expectedLineEnding === '{lf}' ? 'LF' : 'CRLF'
)
.replace(
'{actual}',
incorrectLineEnding === '{lf}' ? 'LF' : 'CRLF'
),
lineNumber: index + i + 1,
startColumnNumber: l.indexOf(incorrectLineEnding),
endColumnNumber: l.indexOf(incorrectLineEnding) + 1,
severity: Severity.Warning
})
}
})
}
})
return diagnostics
}
const fix = (value: string, config?: LintConfig): string => {
const lineEndingConfig = config?.lineEndings || LineEndings.LF
return value
.replace(/\r\n/g, '{crlf}')
.replace(/\n/g, '{lf}')
.replace(/{crlf}/g, lineEndingConfig === LineEndings.LF ? '\n' : '\r\n')
.replace(/{lf}/g, lineEndingConfig === LineEndings.LF ? '\n' : '\r\n')
}
/**
* Lint rule that checks if line endings in a file match the configured type.
*/
export const lineEndings: FileLintRule = {
type: LintRuleType.File,
name,
description,
message,
test,
fix
}

View File

@@ -1,4 +1,5 @@
import { Severity } from '../types/Severity' import { LintConfig } from '../../types'
import { Severity } from '../../types/Severity'
import { noNestedMacros } from './noNestedMacros' import { noNestedMacros } from './noNestedMacros'
describe('noNestedMacros', () => { describe('noNestedMacros', () => {
@@ -29,13 +30,13 @@ describe('noNestedMacros', () => {
message: "Macro definition for 'inner' present in macro 'outer'", message: "Macro definition for 'inner' present in macro 'outer'",
lineNumber: 4, lineNumber: 4,
startColumnNumber: 7, startColumnNumber: 7,
endColumnNumber: 20, endColumnNumber: 21,
severity: Severity.Warning severity: Severity.Warning
} }
]) ])
}) })
it('should return an array with a single diagnostic when nested macros are defined at 2 levels', () => { it('should return an array with two diagnostics when nested macros are defined at 2 levels', () => {
const content = ` const content = `
%macro outer(); %macro outer();
/* any amount of arbitrary code */ /* any amount of arbitrary code */
@@ -52,27 +53,44 @@ describe('noNestedMacros', () => {
%outer()` %outer()`
expect(noNestedMacros.test(content)).toEqual([ expect(noNestedMacros.test(content)).toContainEqual({
{ message: "Macro definition for 'inner' present in macro 'outer'",
message: "Macro definition for 'inner' present in macro 'outer'", lineNumber: 4,
lineNumber: 4, startColumnNumber: 7,
startColumnNumber: 7, endColumnNumber: 21,
endColumnNumber: 20, severity: Severity.Warning
severity: Severity.Warning })
}, expect(noNestedMacros.test(content)).toContainEqual({
{ message: "Macro definition for 'inner2' present in macro 'inner'",
message: "Macro definition for 'inner2' present in macro 'inner'", lineNumber: 7,
lineNumber: 7, startColumnNumber: 17,
startColumnNumber: 17, endColumnNumber: 32,
endColumnNumber: 31, severity: Severity.Warning
severity: Severity.Warning })
}
])
}) })
it('should return an empty array when the file is undefined', () => { it('should return an empty array when the file is undefined', () => {
const content = undefined const content = undefined
expect(noNestedMacros.test((content as unknown) as string)).toEqual([]) expect(noNestedMacros.test(content as unknown as string)).toEqual([])
})
it('should use the configured line ending while testing content', () => {
const content = `%macro outer();\r\n%macro inner;\r\n%mend inner;\r\n%mend outer;`
const diagnostics = noNestedMacros.test(
content,
new LintConfig({ lineEndings: 'crlf' })
)
expect(diagnostics).toEqual([
{
message: "Macro definition for 'inner' present in macro 'outer'",
lineNumber: 2,
startColumnNumber: 1,
endColumnNumber: 13,
severity: Severity.Warning
}
])
}) })
}) })

View File

@@ -0,0 +1,52 @@
import { Diagnostic } from '../../types/Diagnostic'
import { FileLintRule } from '../../types/LintRule'
import { LintRuleType } from '../../types/LintRuleType'
import { Severity } from '../../types/Severity'
import { getColumnNumber } from '../../utils/getColumnNumber'
import { parseMacros } from '../../utils/parseMacros'
import { LintConfig } from '../../types'
import { LineEndings } from '../../types/LineEndings'
const name = 'noNestedMacros'
const description = 'Enfoces the absence of nested macro definitions.'
const message = `Macro definition for '{macro}' present in macro '{parent}'`
const test = (value: string, config?: LintConfig) => {
const lineEnding = config?.lineEndings === LineEndings.CRLF ? '\r\n' : '\n'
const lines: string[] = value ? value.split(lineEnding) : []
const diagnostics: Diagnostic[] = []
const macros = parseMacros(value, config)
macros
.filter((m) => !!m.parentMacro)
.forEach((macro) => {
diagnostics.push({
message: message
.replace('{macro}', macro.name)
.replace('{parent}', macro.parentMacro),
lineNumber: macro.startLineNumber as number,
startColumnNumber: getColumnNumber(
lines[(macro.startLineNumber as number) - 1],
'%macro'
),
endColumnNumber:
getColumnNumber(
lines[(macro.startLineNumber as number) - 1],
'%macro'
) +
lines[(macro.startLineNumber as number) - 1].trim().length -
1,
severity: Severity.Warning
})
})
return diagnostics
}
/**
* Lint rule that checks for the absence of nested macro definitions.
*/
export const noNestedMacros: FileLintRule = {
type: LintRuleType.File,
name,
description,
message,
test
}

View File

@@ -1,107 +0,0 @@
import { Diagnostic } from '../types/Diagnostic'
import { FileLintRule } from '../types/LintRule'
import { LintRuleType } from '../types/LintRuleType'
import { Severity } from '../types/Severity'
import { trimComments } from '../utils/trimComments'
import { getColumnNumber } from '../utils/getColumnNumber'
const name = 'hasMacroNameInMend'
const description =
'Enforces the presence of the macro name in each %mend statement.'
const message = '%mend statement has missing or incorrect macro name'
const test = (value: string) => {
const diagnostics: Diagnostic[] = []
const lines: string[] = value ? value.split('\n') : []
const declaredMacros: { name: string; lineNumber: number }[] = []
let isCommentStarted = false
lines.forEach((line, lineIndex) => {
const { statement: trimmedLine, commentStarted } = trimComments(
line,
isCommentStarted
)
isCommentStarted = commentStarted
const statements: string[] = trimmedLine ? trimmedLine.split(';') : []
statements.forEach((statement) => {
const { statement: trimmedStatement, commentStarted } = trimComments(
statement,
isCommentStarted
)
isCommentStarted = commentStarted
if (trimmedStatement.startsWith('%macro ')) {
const macroName = trimmedStatement
.slice(7, trimmedStatement.length)
.trim()
.split('(')[0]
if (macroName)
declaredMacros.push({
name: macroName,
lineNumber: lineIndex + 1
})
} else if (trimmedStatement.startsWith('%mend')) {
const declaredMacro = declaredMacros.pop()
const macroName = trimmedStatement
.split(' ')
.filter((s: string) => !!s)[1]
if (!declaredMacro) {
diagnostics.push({
message: `%mend statement is redundant`,
lineNumber: lineIndex + 1,
startColumnNumber: getColumnNumber(line, '%mend'),
endColumnNumber:
getColumnNumber(line, '%mend') + trimmedStatement.length,
severity: Severity.Warning
})
} else if (!macroName) {
diagnostics.push({
message: `%mend statement is missing macro name - ${
declaredMacro!.name
}`,
lineNumber: lineIndex + 1,
startColumnNumber: getColumnNumber(line, '%mend'),
endColumnNumber: getColumnNumber(line, '%mend') + 6,
severity: Severity.Warning
})
} else if (macroName !== declaredMacro!.name) {
diagnostics.push({
message: `%mend statement has mismatched macro name, it should be '${
declaredMacro!.name
}'`,
lineNumber: lineIndex + 1,
startColumnNumber: getColumnNumber(line, macroName),
endColumnNumber:
getColumnNumber(line, macroName) + macroName.length - 1,
severity: Severity.Warning
})
}
}
})
})
declaredMacros.forEach((declaredMacro) => {
diagnostics.push({
message: `Missing %mend statement for macro - ${declaredMacro.name}`,
lineNumber: declaredMacro.lineNumber,
startColumnNumber: 1,
endColumnNumber: 1,
severity: Severity.Warning
})
})
return diagnostics
}
/**
* Lint rule that checks for the presence of macro name in %mend statement.
*/
export const hasMacroNameInMend: FileLintRule = {
type: LintRuleType.File,
name,
description,
message,
test
}

View File

@@ -1,85 +0,0 @@
import { Diagnostic } from '../types/Diagnostic'
import { FileLintRule } from '../types/LintRule'
import { LintRuleType } from '../types/LintRuleType'
import { Severity } from '../types/Severity'
import { trimComments } from '../utils/trimComments'
import { getColumnNumber } from '../utils/getColumnNumber'
const name = 'hasMacroParentheses'
const description = 'Enforces the presence of parentheses in macro definitions.'
const message = 'Macro definition missing parentheses'
const test = (value: string) => {
const diagnostics: Diagnostic[] = []
const lines: string[] = value ? value.split('\n') : []
let isCommentStarted = false
lines.forEach((line, lineIndex) => {
const { statement: trimmedLine, commentStarted } = trimComments(
line,
isCommentStarted
)
isCommentStarted = commentStarted
const statements: string[] = trimmedLine ? trimmedLine.split(';') : []
statements.forEach((statement) => {
const { statement: trimmedStatement, commentStarted } = trimComments(
statement,
isCommentStarted
)
isCommentStarted = commentStarted
if (trimmedStatement.startsWith('%macro')) {
const macroNameDefinition = trimmedStatement
.slice(7, trimmedStatement.length)
.trim()
const macroNameDefinitionParts = macroNameDefinition.split('(')
const macroName = macroNameDefinitionParts[0]
if (!macroName)
diagnostics.push({
message: 'Macro definition missing name',
lineNumber: lineIndex + 1,
startColumnNumber: getColumnNumber(line, '%macro'),
endColumnNumber:
getColumnNumber(line, '%macro') + trimmedStatement.length,
severity: Severity.Warning
})
else if (macroNameDefinitionParts.length === 1)
diagnostics.push({
message,
lineNumber: lineIndex + 1,
startColumnNumber: getColumnNumber(line, macroNameDefinition),
endColumnNumber:
getColumnNumber(line, macroNameDefinition) +
macroNameDefinition.length -
1,
severity: Severity.Warning
})
else if (macroName !== macroName.trim())
diagnostics.push({
message: 'Macro definition contains space(s)',
lineNumber: lineIndex + 1,
startColumnNumber: getColumnNumber(line, macroNameDefinition),
endColumnNumber:
getColumnNumber(line, macroNameDefinition) +
macroNameDefinition.length -
1,
severity: Severity.Warning
})
}
})
})
return diagnostics
}
/**
* Lint rule that enforces the presence of parentheses in macro definitions..
*/
export const hasMacroParentheses: FileLintRule = {
type: LintRuleType.File,
name,
description,
message,
test
}

View File

@@ -1,4 +1,4 @@
import { LintConfig, Severity } from '../types' import { LintConfig, Severity } from '../../types'
import { indentationMultiple } from './indentationMultiple' import { indentationMultiple } from './indentationMultiple'
describe('indentationMultiple', () => { describe('indentationMultiple', () => {

View File

@@ -1,7 +1,7 @@
import { LintConfig } from '../types' import { LintConfig } from '../../types'
import { LineLintRule } from '../types/LintRule' import { LineLintRule } from '../../types/LintRule'
import { LintRuleType } from '../types/LintRuleType' import { LintRuleType } from '../../types/LintRuleType'
import { Severity } from '../types/Severity' import { Severity } from '../../types/Severity'
const name = 'indentationMultiple' const name = 'indentationMultiple'
const description = 'Ensure indentation by a multiple of the configured number.' const description = 'Ensure indentation by a multiple of the configured number.'

5
src/rules/line/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export { indentationMultiple } from './indentationMultiple'
export { maxLineLength } from './maxLineLength'
export { noEncodedPasswords } from './noEncodedPasswords'
export { noTabIndentation } from './noTabIndentation'
export { noTrailingSpaces } from './noTrailingSpaces'

View File

@@ -1,4 +1,4 @@
import { LintConfig, Severity } from '../types' import { LintConfig, Severity } from '../../types'
import { maxLineLength } from './maxLineLength' import { maxLineLength } from './maxLineLength'
describe('maxLineLength', () => { describe('maxLineLength', () => {

View File

@@ -1,7 +1,7 @@
import { LintConfig } from '../types' import { LintConfig } from '../../types'
import { LineLintRule } from '../types/LintRule' import { LineLintRule } from '../../types/LintRule'
import { LintRuleType } from '../types/LintRuleType' import { LintRuleType } from '../../types/LintRuleType'
import { Severity } from '../types/Severity' import { Severity } from '../../types/Severity'
const name = 'maxLineLength' const name = 'maxLineLength'
const description = 'Restrict lines to the specified length.' const description = 'Restrict lines to the specified length.'

View File

@@ -1,4 +1,4 @@
import { Severity } from '../types/Severity' import { Severity } from '../../types/Severity'
import { noEncodedPasswords } from './noEncodedPasswords' import { noEncodedPasswords } from './noEncodedPasswords'
describe('noEncodedPasswords', () => { describe('noEncodedPasswords', () => {

View File

@@ -1,6 +1,6 @@
import { LineLintRule } from '../types/LintRule' import { LineLintRule } from '../../types/LintRule'
import { LintRuleType } from '../types/LintRuleType' import { LintRuleType } from '../../types/LintRuleType'
import { Severity } from '../types/Severity' import { Severity } from '../../types/Severity'
const name = 'noEncodedPasswords' const name = 'noEncodedPasswords'
const description = 'Disallow encoded passwords in SAS code.' const description = 'Disallow encoded passwords in SAS code.'

View File

@@ -1,4 +1,4 @@
import { Severity } from '../types/Severity' import { Severity } from '../../types/Severity'
import { noTabIndentation } from './noTabIndentation' import { noTabIndentation } from './noTabIndentation'
describe('noTabs', () => { describe('noTabs', () => {

View File

@@ -1,6 +1,6 @@
import { LineLintRule } from '../types/LintRule' import { LineLintRule } from '../../types/LintRule'
import { LintRuleType } from '../types/LintRuleType' import { LintRuleType } from '../../types/LintRuleType'
import { Severity } from '../types/Severity' import { Severity } from '../../types/Severity'
const name = 'noTabs' const name = 'noTabs'
const description = 'Disallow indenting with tabs.' const description = 'Disallow indenting with tabs.'

View File

@@ -1,4 +1,4 @@
import { Severity } from '../types/Severity' import { Severity } from '../../types/Severity'
import { noTrailingSpaces } from './noTrailingSpaces' import { noTrailingSpaces } from './noTrailingSpaces'
describe('noTrailingSpaces', () => { describe('noTrailingSpaces', () => {

View File

@@ -1,6 +1,6 @@
import { LineLintRule } from '../types/LintRule' import { LineLintRule } from '../../types/LintRule'
import { LintRuleType } from '../types/LintRuleType' import { LintRuleType } from '../../types/LintRuleType'
import { Severity } from '../types/Severity' import { Severity } from '../../types/Severity'
const name = 'noTrailingSpaces' const name = 'noTrailingSpaces'
const description = 'Disallow trailing spaces on lines.' const description = 'Disallow trailing spaces on lines.'
@@ -17,6 +17,7 @@ const test = (value: string, lineNumber: number) =>
severity: Severity.Warning severity: Severity.Warning
} }
] ]
const fix = (value: string) => value.trimEnd()
/** /**
* Lint rule that checks for the presence of trailing space(s) in a given line of text. * Lint rule that checks for the presence of trailing space(s) in a given line of text.
@@ -26,5 +27,6 @@ export const noTrailingSpaces: LineLintRule = {
name, name,
description, description,
message, message,
test test,
fix
} }

View File

@@ -1,68 +0,0 @@
import { Diagnostic } from '../types/Diagnostic'
import { FileLintRule } from '../types/LintRule'
import { LintRuleType } from '../types/LintRuleType'
import { Severity } from '../types/Severity'
import { trimComments } from '../utils/trimComments'
import { getColumnNumber } from '../utils/getColumnNumber'
const name = 'noNestedMacros'
const description = 'Enfoces the absence of nested macro definitions.'
const message = `Macro definition for '{macro}' present in macro '{parent}'`
const test = (value: string) => {
const diagnostics: Diagnostic[] = []
const declaredMacros: string[] = []
const lines: string[] = value ? value.split('\n') : []
let isCommentStarted = false
lines.forEach((line, lineIndex) => {
const { statement: trimmedLine, commentStarted } = trimComments(
line,
isCommentStarted
)
isCommentStarted = commentStarted
const statements: string[] = trimmedLine ? trimmedLine.split(';') : []
statements.forEach((statement) => {
const { statement: trimmedStatement, commentStarted } = trimComments(
statement,
isCommentStarted
)
isCommentStarted = commentStarted
if (trimmedStatement.startsWith('%macro ')) {
const macroName = trimmedStatement
.slice(7, trimmedStatement.length)
.trim()
.split('(')[0]
if (declaredMacros.length) {
const parentMacro = declaredMacros.slice(-1).pop()
diagnostics.push({
message: message
.replace('{macro}', macroName)
.replace('{parent}', parentMacro!),
lineNumber: lineIndex + 1,
startColumnNumber: getColumnNumber(line, '%macro'),
endColumnNumber:
getColumnNumber(line, '%macro') + trimmedStatement.length - 1,
severity: Severity.Warning
})
}
declaredMacros.push(macroName)
} else if (trimmedStatement.startsWith('%mend')) {
declaredMacros.pop()
}
})
})
return diagnostics
}
/**
* Lint rule that checks for the absence of nested macro definitions.
*/
export const noNestedMacros: FileLintRule = {
type: LintRuleType.File,
name,
description,
message,
test
}

2
src/rules/path/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { lowerCaseFileNames } from './lowerCaseFileNames'
export { noSpacesInFileNames } from './noSpacesInFileNames'

View File

@@ -1,4 +1,4 @@
import { Severity } from '../types/Severity' import { Severity } from '../../types/Severity'
import { lowerCaseFileNames } from './lowerCaseFileNames' import { lowerCaseFileNames } from './lowerCaseFileNames'
describe('lowerCaseFileNames', () => { describe('lowerCaseFileNames', () => {

View File

@@ -1,6 +1,6 @@
import { PathLintRule } from '../types/LintRule' import { PathLintRule } from '../../types/LintRule'
import { LintRuleType } from '../types/LintRuleType' import { LintRuleType } from '../../types/LintRuleType'
import { Severity } from '../types/Severity' import { Severity } from '../../types/Severity'
import path from 'path' import path from 'path'
const name = 'lowerCaseFileNames' const name = 'lowerCaseFileNames'

View File

@@ -1,4 +1,4 @@
import { Severity } from '../types/Severity' import { Severity } from '../../types/Severity'
import { noSpacesInFileNames } from './noSpacesInFileNames' import { noSpacesInFileNames } from './noSpacesInFileNames'
describe('noSpacesInFileNames', () => { describe('noSpacesInFileNames', () => {

View File

@@ -1,6 +1,6 @@
import { PathLintRule } from '../types/LintRule' import { PathLintRule } from '../../types/LintRule'
import { LintRuleType } from '../types/LintRuleType' import { LintRuleType } from '../../types/LintRuleType'
import { Severity } from '../types/Severity' import { Severity } from '../../types/Severity'
import path from 'path' import path from 'path'
const name = 'noSpacesInFileNames' const name = 'noSpacesInFileNames'

10
src/types/FormatResult.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Diagnostic } from './Diagnostic'
/**
* Represents the result of a format operation on a file, folder or project.
*/
export interface FormatResult {
updatedFilePaths: string[]
fixedDiagnosticsCount: number
unfixedDiagnostics: Map<string, Diagnostic[]> | Diagnostic[]
}

4
src/types/LineEndings.ts Normal file
View File

@@ -0,0 +1,4 @@
export enum LineEndings {
LF = 'lf',
CRLF = 'crlf'
}

View File

@@ -1,3 +1,4 @@
import { LineEndings } from './LineEndings'
import { LintConfig } from './LintConfig' import { LintConfig } from './LintConfig'
import { LintRuleType } from './LintRuleType' import { LintRuleType } from './LintRuleType'
@@ -108,6 +109,33 @@ describe('LintConfig', () => {
expect(config.indentationMultiple).toEqual(0) expect(config.indentationMultiple).toEqual(0)
}) })
it('should create an instance with the line endings set to LF', () => {
const config = new LintConfig({ lineEndings: 'lf' })
expect(config).toBeTruthy()
expect(config.lineEndings).toEqual(LineEndings.LF)
})
it('should create an instance with the line endings set to CRLF', () => {
const config = new LintConfig({ lineEndings: 'crlf' })
expect(config).toBeTruthy()
expect(config.lineEndings).toEqual(LineEndings.CRLF)
})
it('should create an instance with the line endings set to LF by default', () => {
const config = new LintConfig({})
expect(config).toBeTruthy()
expect(config.lineEndings).toEqual(LineEndings.LF)
})
it('should throw an error with an invalid value for line endings', () => {
expect(() => new LintConfig({ lineEndings: 'test' })).toThrowError(
`Invalid value for lineEndings: can be ${LineEndings.LF} or ${LineEndings.CRLF}`
)
})
it('should create an instance with all flags set', () => { it('should create an instance with all flags set', () => {
const config = new LintConfig({ const config = new LintConfig({
noTrailingSpaces: true, noTrailingSpaces: true,

View File

@@ -1,14 +1,19 @@
import { hasDoxygenHeader } from '../rules/hasDoxygenHeader' import {
import { indentationMultiple } from '../rules/indentationMultiple' hasDoxygenHeader,
import { lowerCaseFileNames } from '../rules/lowerCaseFileNames' hasMacroNameInMend,
import { maxLineLength } from '../rules/maxLineLength' noNestedMacros,
import { noEncodedPasswords } from '../rules/noEncodedPasswords' hasMacroParentheses,
import { noSpacesInFileNames } from '../rules/noSpacesInFileNames' lineEndings
import { noTabIndentation } from '../rules/noTabIndentation' } from '../rules/file'
import { noTrailingSpaces } from '../rules/noTrailingSpaces' import {
import { hasMacroNameInMend } from '../rules/hasMacroNameInMend' indentationMultiple,
import { noNestedMacros } from '../rules/noNestedMacros' maxLineLength,
import { hasMacroParentheses } from '../rules/hasMacroParentheses' noEncodedPasswords,
noTabIndentation,
noTrailingSpaces
} from '../rules/line'
import { lowerCaseFileNames, noSpacesInFileNames } from '../rules/path'
import { LineEndings } from './LineEndings'
import { FileLintRule, LineLintRule, PathLintRule } from './LintRule' import { FileLintRule, LineLintRule, PathLintRule } from './LintRule'
/** /**
@@ -24,6 +29,7 @@ export class LintConfig {
readonly pathLintRules: PathLintRule[] = [] readonly pathLintRules: PathLintRule[] = []
readonly maxLineLength: number = 80 readonly maxLineLength: number = 80
readonly indentationMultiple: number = 2 readonly indentationMultiple: number = 2
readonly lineEndings: LineEndings = LineEndings.LF
constructor(json?: any) { constructor(json?: any) {
if (json?.noTrailingSpaces) { if (json?.noTrailingSpaces) {
@@ -43,6 +49,19 @@ export class LintConfig {
this.lineLintRules.push(maxLineLength) this.lineLintRules.push(maxLineLength)
} }
if (json?.lineEndings) {
if (
json.lineEndings !== LineEndings.LF &&
json.lineEndings !== LineEndings.CRLF
) {
throw new Error(
`Invalid value for lineEndings: can be ${LineEndings.LF} or ${LineEndings.CRLF}`
)
}
this.lineEndings = json.lineEndings
this.fileLintRules.push(lineEndings)
}
if (!isNaN(json?.indentationMultiple)) { if (!isNaN(json?.indentationMultiple)) {
this.indentationMultiple = json.indentationMultiple as number this.indentationMultiple = json.indentationMultiple as number
this.lineLintRules.push(indentationMultiple) this.lineLintRules.push(indentationMultiple)

View File

@@ -19,6 +19,7 @@ export interface LintRule {
export interface LineLintRule extends LintRule { export interface LineLintRule extends LintRule {
type: LintRuleType.Line type: LintRuleType.Line
test: (value: string, lineNumber: number, config?: LintConfig) => Diagnostic[] test: (value: string, lineNumber: number, config?: LintConfig) => Diagnostic[]
fix?: (value: string, config?: LintConfig) => string
} }
/** /**
@@ -26,7 +27,8 @@ export interface LineLintRule extends LintRule {
*/ */
export interface FileLintRule extends LintRule { export interface FileLintRule extends LintRule {
type: LintRuleType.File type: LintRuleType.File
test: (value: string) => Diagnostic[] test: (value: string, config?: LintConfig) => Diagnostic[]
fix?: (value: string, config?: LintConfig) => string
} }
/** /**

View File

@@ -1,4 +1,5 @@
export * from './Diagnostic' export * from './Diagnostic'
export * from './FormatResult'
export * from './LintConfig' export * from './LintConfig'
export * from './LintRule' export * from './LintRule'
export * from './LintRuleType' export * from './LintRuleType'

View File

@@ -2,6 +2,10 @@ import * as fileModule from '@sasjs/utils/file'
import { LintConfig } from '../types/LintConfig' import { LintConfig } from '../types/LintConfig'
import { getLintConfig } from './getLintConfig' import { getLintConfig } from './getLintConfig'
const expectedFileLintRulesCount = 4
const expectedLineLintRulesCount = 5
const expectedPathLintRulesCount = 2
describe('getLintConfig', () => { describe('getLintConfig', () => {
it('should get the lint config', async () => { it('should get the lint config', async () => {
const config = await getLintConfig() const config = await getLintConfig()
@@ -17,8 +21,8 @@ describe('getLintConfig', () => {
const config = await getLintConfig() const config = await getLintConfig()
expect(config).toBeInstanceOf(LintConfig) expect(config).toBeInstanceOf(LintConfig)
expect(config.fileLintRules.length).toEqual(3) expect(config.fileLintRules.length).toEqual(expectedFileLintRulesCount)
expect(config.lineLintRules.length).toEqual(5) expect(config.lineLintRules.length).toEqual(expectedLineLintRulesCount)
expect(config.pathLintRules.length).toEqual(2) expect(config.pathLintRules.length).toEqual(expectedPathLintRulesCount)
}) })
}) })

View File

@@ -15,7 +15,7 @@ export const DefaultLintConfiguration = {
maxLineLength: 80, maxLineLength: 80,
noTabIndentation: true, noTabIndentation: true,
indentationMultiple: 2, indentationMultiple: 2,
hasMacroNameInMend: false, hasMacroNameInMend: true,
noNestedMacros: true, noNestedMacros: true,
hasMacroParentheses: true hasMacroParentheses: true
} }

View File

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

View File

@@ -0,0 +1,105 @@
import { LintConfig } from '../types'
import { parseMacros } from './parseMacros'
describe('parseMacros', () => {
it('should return an array with a single macro', () => {
const text = `%macro test;
%put 'hello';
%mend`
const macros = parseMacros(text, new LintConfig())
expect(macros.length).toEqual(1)
expect(macros).toContainEqual({
name: 'test',
declarationLine: '%macro test;',
terminationLine: '%mend',
declaration: '%macro test',
termination: '%mend',
startLineNumber: 1,
endLineNumber: 3,
parentMacro: '',
hasMacroNameInMend: false,
hasParentheses: false,
mismatchedMendMacroName: ''
})
})
it('should return an array with multiple macros', () => {
const text = `%macro foo;
%put 'foo';
%mend;
%macro bar();
%put 'bar';
%mend bar;`
const macros = parseMacros(text, new LintConfig())
expect(macros.length).toEqual(2)
expect(macros).toContainEqual({
name: 'foo',
declarationLine: '%macro foo;',
terminationLine: '%mend;',
declaration: '%macro foo',
termination: '%mend',
startLineNumber: 1,
endLineNumber: 3,
parentMacro: '',
hasMacroNameInMend: false,
hasParentheses: false,
mismatchedMendMacroName: ''
})
expect(macros).toContainEqual({
name: 'bar',
declarationLine: '%macro bar();',
terminationLine: '%mend bar;',
declaration: '%macro bar()',
termination: '%mend bar',
startLineNumber: 4,
endLineNumber: 6,
parentMacro: '',
hasMacroNameInMend: true,
hasParentheses: true,
mismatchedMendMacroName: ''
})
})
it('should detect nested macro definitions', () => {
const text = `%macro test()
%put 'hello';
%macro test2
%put 'world;
%mend
%mend test`
const macros = parseMacros(text, new LintConfig())
expect(macros.length).toEqual(2)
expect(macros).toContainEqual({
name: 'test',
declarationLine: '%macro test()',
terminationLine: '%mend test',
declaration: '%macro test()',
termination: '%mend test',
startLineNumber: 1,
endLineNumber: 6,
parentMacro: '',
hasMacroNameInMend: true,
hasParentheses: true,
mismatchedMendMacroName: ''
})
expect(macros).toContainEqual({
name: 'test2',
declarationLine: ' %macro test2',
terminationLine: ' %mend',
declaration: '%macro test2',
termination: '%mend',
startLineNumber: 3,
endLineNumber: 5,
parentMacro: 'test',
hasMacroNameInMend: false,
hasParentheses: false,
mismatchedMendMacroName: ''
})
})
})

97
src/utils/parseMacros.ts Normal file
View File

@@ -0,0 +1,97 @@
import { LintConfig } from '../types/LintConfig'
import { LineEndings } from '../types/LineEndings'
import { trimComments } from './trimComments'
interface Macro {
name: string
startLineNumber: number | null
endLineNumber: number | null
declarationLine: string
terminationLine: string
declaration: string
termination: string
parentMacro: string
hasMacroNameInMend: boolean
hasParentheses: boolean
mismatchedMendMacroName: string
}
export const parseMacros = (text: string, config?: LintConfig): Macro[] => {
const lineEnding = config?.lineEndings === LineEndings.CRLF ? '\r\n' : '\n'
const lines: string[] = text ? text.split(lineEnding) : []
const macros: Macro[] = []
let isCommentStarted = false
let macroStack: Macro[] = []
lines.forEach((line, index) => {
const { statement: trimmedLine, commentStarted } = trimComments(
line,
isCommentStarted
)
isCommentStarted = commentStarted
const statements: string[] = trimmedLine ? trimmedLine.split(';') : []
statements.forEach((statement) => {
const { statement: trimmedStatement, commentStarted } = trimComments(
statement,
isCommentStarted
)
isCommentStarted = commentStarted
if (trimmedStatement.startsWith('%macro')) {
const startLineNumber = index + 1
const name = trimmedStatement
.slice(7, trimmedStatement.length)
.trim()
.split('(')[0]
macroStack.push({
name,
startLineNumber,
endLineNumber: null,
parentMacro: macroStack.length
? macroStack[macroStack.length - 1].name
: '',
hasParentheses: trimmedStatement.endsWith('()'),
hasMacroNameInMend: false,
mismatchedMendMacroName: '',
declarationLine: line,
terminationLine: '',
declaration: trimmedStatement,
termination: ''
})
} else if (trimmedStatement.startsWith('%mend')) {
if (macroStack.length) {
const macro = macroStack.pop() as Macro
const mendMacroName =
trimmedStatement.split(' ').filter((s: string) => !!s)[1] || ''
macro.endLineNumber = index + 1
macro.hasMacroNameInMend = mendMacroName === macro.name
macro.mismatchedMendMacroName = macro.hasMacroNameInMend
? ''
: mendMacroName
macro.terminationLine = line
macro.termination = trimmedStatement
macros.push(macro)
} else {
macros.push({
name: '',
startLineNumber: null,
endLineNumber: index + 1,
parentMacro: '',
hasParentheses: false,
hasMacroNameInMend: false,
mismatchedMendMacroName: '',
declarationLine: '',
terminationLine: line,
declaration: '',
termination: trimmedStatement
})
}
}
})
})
macros.push(...macroStack)
return macros
}

View File

@@ -0,0 +1,41 @@
import { LintConfig } from '../types'
import { splitText } from './splitText'
describe('splitText', () => {
const config = new LintConfig({
noTrailingSpaces: true,
noEncodedPasswords: true,
hasDoxygenHeader: true,
noSpacesInFileNames: true,
maxLineLength: 80,
lowerCaseFileNames: true,
noTabIndentation: true,
indentationMultiple: 2,
hasMacroNameInMend: true,
noNestedMacros: true,
hasMacroParentheses: true,
lineEndings: 'lf'
})
it('should return an empty array when text is falsy', () => {
const lines = splitText('', config)
expect(lines.length).toEqual(0)
})
it('should return an array of lines from text', () => {
const lines = splitText(`line 1\nline 2`, config)
expect(lines.length).toEqual(2)
expect(lines[0]).toEqual('line 1')
expect(lines[1]).toEqual('line 2')
})
it('should work with CRLF line endings', () => {
const lines = splitText(`line 1\r\nline 2`, config)
expect(lines.length).toEqual(2)
expect(lines[0]).toEqual('line 1')
expect(lines[1]).toEqual('line 2')
})
})

17
src/utils/splitText.ts Normal file
View File

@@ -0,0 +1,17 @@
import { LintConfig } from '../types/LintConfig'
import { LineEndings } from '../types/LineEndings'
/**
* Splits the given content into a list of lines, regardless of CRLF or LF line endings.
* @param {string} text - the text content to be split into lines.
* @returns {string[]} an array of lines from the given text
*/
export const splitText = (text: string, config: LintConfig): string[] => {
if (!text) return []
const expectedLineEndings =
config.lineEndings === LineEndings.LF ? '\n' : '\r\n'
const incorrectLineEndings = expectedLineEndings === '\n' ? '\r\n' : '\n'
return text
.replace(new RegExp(incorrectLineEndings, 'g'), expectedLineEndings)
.split(expectedLineEndings)
}