diff --git a/src/rules/commentFormatRule.ts b/src/rules/commentFormatRule.ts index e65a16b3fc3..bcaab54a377 100644 --- a/src/rules/commentFormatRule.ts +++ b/src/rules/commentFormatRule.ts @@ -18,6 +18,14 @@ import * as ts from "typescript"; import * as Lint from "../index"; +import { escapeRegExp } from "../utils"; + +interface IExceptionsObject { + ignoreWords?: string[]; + ignorePattern?: string; +} + +type ExceptionsRegExp = RegExp | null; const OPTION_SPACE = "check-space"; const OPTION_LOWERCASE = "check-lowercase"; @@ -35,17 +43,53 @@ export class Rule extends Lint.Rules.AbstractRule { * \`"check-space"\` requires that all single-line comments must begin with a space, as in \`// comment\` * note that comments starting with \`///\` are also allowed, for things such as \`///\` * \`"check-lowercase"\` requires that the first non-whitespace character of a comment must be lowercase, if applicable. - * \`"check-uppercase"\` requires that the first non-whitespace character of a comment must be uppercase, if applicable.`, + * \`"check-uppercase"\` requires that the first non-whitespace character of a comment must be uppercase, if applicable. + + Exceptions to \`"check-lowercase"\` or \`"check-uppercase"\` can be managed with object that may be passed as last argument. + + One of two options can be provided in this object: + + * \`"ignoreWords"\` - array of strings - words that will be ignored at the beginning of the comment. + * \`"ignorePattern"\` - string - RegExp pattern that will be ignored at the beginning of the comment. + `, options: { type: "array", items: { - type: "string", - enum: ["check-space", "check-lowercase", "check-uppercase"], + anyOf: [ + { + type: "string", + enum: [ + "check-space", + "check-lowercase", + "check-uppercase", + ], + }, + { + type: "object", + properties: { + ignoreWords: { + type: "array", + items: { + type: "string", + }, + }, + ignorePattern: { + type: "string", + }, + }, + minProperties: 1, + maxProperties: 1, + }, + ], }, minLength: 1, - maxLength: 3, + maxLength: 4, }, - optionExamples: ['[true, "check-space", "check-lowercase"]'], + optionExamples: [ + '[true, "check-space", "check-uppercase"]', + '[true, "check-lowercase", {"ignoreWords": ["TODO", "HACK"]}]', + '[true, "check-lowercase", {"ignorePattern": "STD\\w{2,3}\\b"}]', + ], type: "style", typescriptOnly: false, }; @@ -54,6 +98,8 @@ export class Rule extends Lint.Rules.AbstractRule { public static LOWERCASE_FAILURE = "comment must start with lowercase letter"; public static UPPERCASE_FAILURE = "comment must start with uppercase letter"; public static LEADING_SPACE_FAILURE = "comment must start with a space"; + public static IGNORE_WORDS_FAILURE_FACTORY = (words: string[]): string => ` or the word(s): ${words.join(", ")}`; + public static IGNORE_PATTERN_FAILURE_FACTORY = (pattern: string): string => ` or its start must match the regex pattern "${pattern}"`; public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { return this.applyWithWalker(new CommentWalker(sourceFile, this.getOptions())); @@ -61,6 +107,15 @@ export class Rule extends Lint.Rules.AbstractRule { } class CommentWalker extends Lint.SkippableTokenAwareRuleWalker { + private exceptionsRegExp: ExceptionsRegExp; + private failureIgnorePart: string = ""; + + constructor(sourceFile: ts.SourceFile, options: Lint.IOptions) { + super(sourceFile, options); + + this.exceptionsRegExp = this.composeExceptionsRegExp(); + } + public visitSourceFile(node: ts.SourceFile) { super.visitSourceFile(node); Lint.scanAllTokens(ts.createScanner(ts.ScriptTarget.ES5, false, ts.LanguageVariant.Standard, node.text), (scanner: ts.Scanner) => { @@ -82,19 +137,57 @@ class CommentWalker extends Lint.SkippableTokenAwareRuleWalker { } } if (this.hasOption(OPTION_LOWERCASE)) { - if (!startsWithLowercase(commentText)) { - this.addFailureAt(startPosition, width, Rule.LOWERCASE_FAILURE); + if (!startsWithLowercase(commentText) && !this.startsWithException(commentText)) { + this.addFailureAt(startPosition, width, Rule.LOWERCASE_FAILURE + this.failureIgnorePart); } } if (this.hasOption(OPTION_UPPERCASE)) { - if (!startsWithUppercase(commentText) && !isEnableDisableFlag(commentText)) { - this.addFailureAt(startPosition, width, Rule.UPPERCASE_FAILURE); + if (!startsWithUppercase(commentText) && !isEnableDisableFlag(commentText) && !this.startsWithException(commentText)) { + this.addFailureAt(startPosition, width, Rule.UPPERCASE_FAILURE + this.failureIgnorePart); } } } }); } + private startsWithException(commentText: string): boolean { + if (this.exceptionsRegExp == null) { + return false; + } + + return this.exceptionsRegExp.test(commentText); + } + + private composeExceptionsRegExp(): ExceptionsRegExp { + const optionsList = this.getOptions() as Array; + const exceptionsObject = optionsList[optionsList.length - 1]; + + // early return if last element is string instead of exceptions object + if (typeof exceptionsObject === "string" || !exceptionsObject) { + return null; + } + + if (exceptionsObject.ignorePattern) { + this.failureIgnorePart = Rule.IGNORE_PATTERN_FAILURE_FACTORY(exceptionsObject.ignorePattern); + // regex is "start of string"//"any amount of whitespace" followed by user provided ignore pattern + return new RegExp(`^//\\s*(${exceptionsObject.ignorePattern})`); + } + + if (exceptionsObject.ignoreWords) { + this.failureIgnorePart = Rule.IGNORE_WORDS_FAILURE_FACTORY(exceptionsObject.ignoreWords); + // Converts all exceptions values to strings, trim whitespace, escapes RegExp special characters and combines into alternation + const wordsPattern = exceptionsObject.ignoreWords + .map(String) + .map((str) => str.trim()) + .map(escapeRegExp) + .join("|"); + + // regex is "start of string"//"any amount of whitespace"("any word from ignore list") followed by non alphanumeric character + return new RegExp(`^//\\s*(${wordsPattern})\\b`); + } + + return null; + } } function startsWith(commentText: string, changeCase: (str: string) => string) { diff --git a/src/utils.ts b/src/utils.ts index 6dbf524e026..8820f3cdd43 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -92,3 +92,10 @@ export function stripComments(content: string): string { }); return result; }; + +/** + * Escapes all special characters in RegExp pattern to avoid broken regular expressions and ensure proper matches + */ +export function escapeRegExp(re: string): string { + return re.replace(/[.+*?|^$[\]{}()\\]/g, "\\$&"); +} diff --git a/test/rules/comment-format/exceptions-pattern/test.js.lint b/test/rules/comment-format/exceptions-pattern/test.js.lint new file mode 100644 index 00000000000..34b4a0818fa --- /dev/null +++ b/test/rules/comment-format/exceptions-pattern/test.js.lint @@ -0,0 +1,38 @@ +class Clazz { // This comment is correct + /* block comment + * adada + */ + public funcxion() { // this comment has a lowercase letter starting it + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [upper] + //this comment is on its own line, and starts with a lowercase _and_ no space + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [upper] + console.log("test"); //This comment has no space + } + /// +} + +//#region test +//#endregion + +`${location.protocol}//${location.hostname}` + +// tslint should show error here + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [upper] + +// tslint: not a rule flag + ~~~~~~~~~~~~~~~~~~~~~~~~ [upper] + +class Invalid {} + +// tslint:disable-next-line:no-unused-expression +class Valid {} + +// todo write more tests + ~~~~~~~~~~~~~~~~~~~~~~ [upper] + +// STDIN for input +// STDOUT for output +// stderr for errors + + +[upper]: comment must start with uppercase letter or its start must match the regex pattern "std(in|out|err)\b" diff --git a/test/rules/comment-format/exceptions-pattern/test.ts.lint b/test/rules/comment-format/exceptions-pattern/test.ts.lint new file mode 100644 index 00000000000..42adb252e09 --- /dev/null +++ b/test/rules/comment-format/exceptions-pattern/test.ts.lint @@ -0,0 +1,36 @@ +class Clazz { // this comment is correct + /* block comment + * adada + */ + public funcxion() { // This comment has a capital letter starting it + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [lower] + //This comment is on its own line, and starts with a capital _and_ no space + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [lower] + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [space] + console.log("test"); //this comment has no space + ~~~~~~~~~~~~~~~~~~~~~~~~~ [space] + } + /// +} + +//#region test +//#endregion + +`${location.protocol}//${location.hostname}` + +//noinspection JSUnusedGlobalSymbols +const unusedVar = 'unneeded value'; + +// TODO: Write more tests + ~~~~~~~~~~~~~~~~~~~~~~~ [lower] +// HACKING is not an exception + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [lower] + +// STDIN for input +// STDOUT for output +// stderr for errors + + + +[lower]: comment must start with lowercase letter or its start must match the regex pattern "STD\w{2,3}" +[space]: comment must start with a space diff --git a/test/rules/comment-format/exceptions-pattern/tslint.json b/test/rules/comment-format/exceptions-pattern/tslint.json new file mode 100644 index 00000000000..012c339dec3 --- /dev/null +++ b/test/rules/comment-format/exceptions-pattern/tslint.json @@ -0,0 +1,8 @@ +{ + "rules": { + "comment-format": [true, "check-space", "check-lowercase", {"ignorePattern": "STD\\w{2,3}"}] + }, + "jsRules": { + "comment-format": [true, "check-uppercase", {"ignorePattern": "std(in|out|err)\\b"}] + } +} diff --git a/test/rules/comment-format/exceptions-words/test.js.lint b/test/rules/comment-format/exceptions-words/test.js.lint new file mode 100644 index 00000000000..309e97d9e1d --- /dev/null +++ b/test/rules/comment-format/exceptions-words/test.js.lint @@ -0,0 +1,37 @@ +class Clazz { // This comment is correct + /* block comment + * adada + */ + public funcxion() { // this comment has a lowercase letter starting it + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [upper] + //this comment is on its own line, and starts with a lowercase _and_ no space + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [upper] + console.log("test"); //This comment has no space + } + /// +} + +//#region test +//#endregion + +`${location.protocol}//${location.hostname}` + +// tslint should show error here + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [upper] + +// tslint: not a rule flag + ~~~~~~~~~~~~~~~~~~~~~~~~ [upper] + +class Invalid {} + +// tslint:disable-next-line:no-unused-expression +class Valid {} + +// todo write more tests + +// STDIN for input +// STDOUT for output +// stderr for errors + ~~~~~~~~~~~~~~~~~~ [upper] + +[upper]: comment must start with uppercase letter or the word(s): todo diff --git a/test/rules/comment-format/exceptions-words/test.ts.lint b/test/rules/comment-format/exceptions-words/test.ts.lint new file mode 100644 index 00000000000..aa7103ff2a4 --- /dev/null +++ b/test/rules/comment-format/exceptions-words/test.ts.lint @@ -0,0 +1,37 @@ +class Clazz { // this comment is correct + /* block comment + * adada + */ + public funcxion() { // This comment has a capital letter starting it + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [lower] + //This comment is on its own line, and starts with a capital _and_ no space + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [lower] + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [space] + console.log("test"); //this comment has no space + ~~~~~~~~~~~~~~~~~~~~~~~~~ [space] + } + /// +} + +//#region test +//#endregion + +`${location.protocol}//${location.hostname}` + +//noinspection JSUnusedGlobalSymbols +const unusedVar = 'unneeded value'; + +// TODO: Write more tests + +// HACKING is not an exception + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [lower] + +// STDIN for input + ~~~~~~~~~~~~~~~~ [lower] +// STDOUT for output + ~~~~~~~~~~~~~~~~~~ [lower] +// stderr for errors + + +[lower]: comment must start with lowercase letter or the word(s): TODO, HACK +[space]: comment must start with a space diff --git a/test/rules/comment-format/exceptions-words/tslint.json b/test/rules/comment-format/exceptions-words/tslint.json new file mode 100644 index 00000000000..91aa3c20aac --- /dev/null +++ b/test/rules/comment-format/exceptions-words/tslint.json @@ -0,0 +1,8 @@ +{ + "rules": { + "comment-format": [true, "check-space", "check-lowercase", {"ignoreWords": ["TODO", "HACK"]}] + }, + "jsRules": { + "comment-format": [true, "check-uppercase", {"ignoreWords": ["todo"]}] + } +} diff --git a/test/utilsTests.ts b/test/utilsTests.ts index 52206e3d151..37cfbcee5e3 100644 --- a/test/utilsTests.ts +++ b/test/utilsTests.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import {arrayify, dedent, objectify} from "../src/utils"; +import {arrayify, dedent, escapeRegExp, objectify} from "../src/utils"; describe("Utils", () => { it("arrayify", () => { @@ -46,4 +46,15 @@ describe("Utils", () => { assert.equal(dedent` `, " "); assert.equal(dedent``, ""); }); + + it("escapeRegExp", () => { + const plus = escapeRegExp("(a+|d)?b[ci]{2,}"); + const plusRe = new RegExp(plus); + + // contains substring that matches regular expression pattern + assert.equal(plusRe.test("regexpaaaabcicmatch"), false); + + // properly matches exact string with special characters + assert.equal(plusRe.test("string(a+|d)?b[ci]{2,}match"), true); + }); });