Skip to content
This repository has been archived by the owner on Mar 25, 2021. It is now read-only.

Added possibility to enforce different file name casings #4206

Merged
18 changes: 5 additions & 13 deletions .vscode/launch.json
Expand Up @@ -5,15 +5,13 @@
"name": "Debug CLI",
"type": "node",
"request": "launch",
"program": "${workspaceRoot}/build/src/tslint-cli.js",
"program": "${workspaceRoot}/build/src/tslintCli.js",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh, mind taking these changes out into a separate PR? Definitely a bug in launch.json that the file hasn't been renamed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't mind at all, it's better to separate those things. Reverted my changes and will open a new PR for that fix.

"stopOnEntry": false,
"args": [],
"cwd": "${workspaceRoot}",
"preLaunchTask": "tsc",
"runtimeExecutable": null,
"runtimeArgs": [
"--nolazy"
],
"runtimeArgs": ["--nolazy"],
"env": {
"NODE_ENV": "development"
},
Expand All @@ -32,9 +30,7 @@
"cwd": "${workspaceRoot}",
"preLaunchTask": "tsc",
"runtimeExecutable": null,
"runtimeArgs": [
"--nolazy"
],
"runtimeArgs": ["--nolazy"],
"env": {
"NODE_ENV": "development"
},
Expand All @@ -53,9 +49,7 @@
"cwd": "${workspaceRoot}",
"preLaunchTask": "tsc",
"runtimeExecutable": null,
"runtimeArgs": [
"--nolazy"
],
"runtimeArgs": ["--nolazy"],
"env": {
"NODE_ENV": "development"
},
Expand All @@ -74,9 +68,7 @@
"cwd": "${workspaceRoot}",
"preLaunchTask": "tsc",
"runtimeExecutable": null,
"runtimeArgs": [
"--nolazy"
],
"runtimeArgs": ["--nolazy"],
"env": {
"NODE_ENV": "development"
},
Expand Down
142 changes: 126 additions & 16 deletions src/rules/fileNameCasingRule.ts
Expand Up @@ -18,14 +18,15 @@
import * as path from "path";
import * as ts from "typescript";

import { showWarningOnce } from "../error";
import * as Lint from "../index";
import { isCamelCased, isKebabCased, isPascalCased, isSnakeCased } from "../utils";

enum Casing {
CamelCase = "camel-case",
PascalCase = "pascal-case",
KebabCase = "kebab-case",
SnakeCase = "snake-case",
SnakeCase = "snake-case"
}

export class Rule extends Lint.Rules.AbstractRule {
Expand All @@ -35,37 +36,73 @@ export class Rule extends Lint.Rules.AbstractRule {
description: "Enforces a consistent file naming convention",
rationale: "Helps maintain a consistent style across a file hierarchy",
optionsDescription: Lint.Utils.dedent`
One of the following arguments must be provided:
One argument which is either a string defining the file casing or an array consisting of a file name
matches and corresponding casing strings.

* \`${Casing.CamelCase}\`: File names must be camel-cased: \`fileName.ts\`.
* \`${Casing.PascalCase}\`: File names must be Pascal-cased: \`FileName.ts\`.
* \`${Casing.KebabCase}\`: File names must be kebab-cased: \`file-name.ts\`.
* \`${Casing.SnakeCase}\`: File names must be snake-cased: \`file_name.ts\`.`,
* In both cases the casing string must be one of the options:
** \`${Casing.CamelCase}\`: File names must be camel-cased: \`fileName.ts\`.
** \`${Casing.PascalCase}\`: File names must be pascal-cased: \`FileName.ts\`.
** \`${Casing.KebabCase}\`: File names must be kebab-cased: \`file-name.ts\`.
** \`${Casing.SnakeCase}\`: File names must be snake-cased: \`file_name.ts\`.

* The array again consists of array with two items. The first item must be a case-insenstive
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The array again

Should this be updated to mention that it takes in an object?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, forgot that, thanks. I changed it now but since English isn't my first language feel free to point out anything that could be phrased better!

regular expression to match files, the second item must be a valid casing option (see above)`,
options: {
type: "array",
items: [
oneOf: [
{
type: "string",
enum: [Casing.CamelCase, Casing.PascalCase, Casing.KebabCase, Casing.SnakeCase],
enum: [Casing.CamelCase, Casing.PascalCase, Casing.KebabCase, Casing.SnakeCase]
},
],
{
type: "object",
additionalProperties: {
type: "string",
enum: [
Casing.CamelCase,
Casing.PascalCase,
Casing.KebabCase,
Casing.SnakeCase
]
},
minProperties: 1
}
]
},
optionExamples: [
[true, Casing.CamelCase],
[true, Casing.PascalCase],
[true, Casing.KebabCase],
[true, Casing.SnakeCase],
[
true,
[
[".style.ts$", Casing.KebabCase],
[".tsx$", Casing.PascalCase],
[".*", Casing.CamelCase]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's generally convention to use objects for key/value pairs. Unless you have a specific need, please switch to something like:

{
    ".style.ts$": Casing.KebabCase,
    ".tsx$": Casing.PascalCase,
    ".*": Casing.CamelCase,
}

One benefit, in addition to being semantically more clear, is that you get rid of duplicate keys easily.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will switch to object, thanks!
I'm embarassed to admit how long it took me to realize that the syntax in options is actually JSON schema (since I never really worked with that). After that I optimized the declaration a bit, looking forward to your opinion after I pushed my changes.
Maybe the comment for options in IRuleMetadata should begin with "JSON schema ..." for people like me 😄

]
]
],
hasFix: false,
type: "style",
typescriptOnly: false,
typescriptOnly: false
};
/* tslint:enable:object-literal-sort-keys */

private static readonly validCasingOptions = new Set([
Casing.CamelCase,
Casing.KebabCase,
Casing.PascalCase,
Casing.SnakeCase
]);

private static FAILURE_STRING(expectedCasing: Casing): string {
return `File name must be ${Rule.stylizedNameForCasing(expectedCasing)}`;
}

private static isValidCasingOption(casing: string): boolean {
return Rule.validCasingOptions.has(casing as Casing);
}

private static stylizedNameForCasing(casing: Casing): string {
switch (casing) {
case Casing.CamelCase:
Expand All @@ -79,7 +116,7 @@ export class Rule extends Lint.Rules.AbstractRule {
}
}

private static isCorrectCasing(fileName: string, casing: Casing): boolean {
private static hasFileNameCorrectCasing(fileName: string, casing: Casing): boolean {
switch (casing) {
case Casing.CamelCase:
return isCamelCased(fileName);
Expand All @@ -92,16 +129,89 @@ export class Rule extends Lint.Rules.AbstractRule {
}
}

private static isValidRegExp(regExpString: string) {
try {
RegExp(regExpString);
return true;
} catch (e) {
return false;
}
}

private static getCasingFromStringArgument(ruleArgument: string): Casing | undefined {
if (!Rule.isValidCasingOption(ruleArgument)) {
Rule.showWarning(`Unexpected casing option provided: ${ruleArgument}`);
return undefined;
}

return ruleArgument as Casing;
}

private static getCasingFromObjectArgument(
ruleArgument: { [index: string]: string },
fileBaseName: string
): Casing | undefined {
const fileNameMatches = Object.keys(ruleArgument);

if (fileNameMatches.length === 0) {
Rule.showWarning(`Atleast one file name match must be provided`);
return undefined;
}

const matchingFileNameMatch = fileNameMatches.find(fileNameMatch => {
if (!this.isValidRegExp(fileNameMatch)) {
Rule.showWarning(`Invalid regular expression provided: ${fileNameMatch}`);
return false;
}

if (!Rule.isValidCasingOption(ruleArgument[fileNameMatch])) {
Rule.showWarning(`Unexpected casing option provided: ${ruleArgument}`);
return false;
}

return RegExp(fileNameMatch, "i").test(fileBaseName);
});

if (matchingFileNameMatch === undefined) {
return undefined;
}

return ruleArgument[matchingFileNameMatch] as Casing;
}

private static showWarning(message: string): void {
showWarningOnce(`Warning: ${Rule.metadata.ruleName} - ${message}`);
}

public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] {
if (this.ruleArguments.length !== 1) {
Rule.showWarning("Provide a rule option as string or object");
return [];
}

const ruleArgument = this.ruleArguments[0];

let casing: Casing | undefined;

const parsedPath = path.parse(sourceFile.fileName);

if (typeof ruleArgument === "object") {
const objectRuleArgument = ruleArgument as { [index: string]: string };
casing = Rule.getCasingFromObjectArgument(objectRuleArgument, parsedPath.base);
} else if (typeof ruleArgument === "string") {
casing = Rule.getCasingFromStringArgument(ruleArgument);
} else {
Rule.showWarning("Received unexpected rule option");
return [];
}

if (casing === undefined) {
return [];
}

const casing = this.ruleArguments[0] as Casing;
const fileName = path.parse(sourceFile.fileName).name;
if (!Rule.isCorrectCasing(fileName, casing)) {
if (!Rule.hasFileNameCorrectCasing(parsedPath.name, casing)) {
return [
new Lint.RuleFailure(sourceFile, 0, 0, Rule.FAILURE_STRING(casing), this.ruleName),
new Lint.RuleFailure(sourceFile, 0, 0, Rule.FAILURE_STRING(casing), this.ruleName)
];
}

Expand Down
5 changes: 5 additions & 0 deletions test/rules/file-name-casing/camel-case/tslint.json
@@ -0,0 +1,5 @@
{
"rules": {
"file-name-casing": [true, "camel-case"]
}
}
5 changes: 0 additions & 5 deletions test/rules/file-name-casing/default/tslint.json

This file was deleted.

@@ -0,0 +1,2 @@

~nil [File name must be PascalCase]
@@ -0,0 +1,2 @@

~nil [File name must be camelCase]
@@ -0,0 +1,2 @@

~nil [File name must be kebab-case]
13 changes: 13 additions & 0 deletions test/rules/file-name-casing/file-matcher/tslint.json
@@ -0,0 +1,13 @@
{
"rules": {
"file-name-casing": [
true,
{
".*)": "snake-case", // making sure invalid RegExp will be ignored
"pascal.?case": "pascal-case",
"tsx$": "kebab-case",
".*": "camel-case"
}
]
}
}
5 changes: 5 additions & 0 deletions test/rules/file-name-casing/invalid-option/tslint.json
@@ -0,0 +1,5 @@
{
"rules": {
"file-name-casing": [true, "invalid-option"]
}
}
Empty file.
2 changes: 2 additions & 0 deletions test/rules/file-name-casing/kebab-case/noKebabCase.ts.lint
@@ -0,0 +1,2 @@

~nil [File name must be kebab-case]
5 changes: 5 additions & 0 deletions test/rules/file-name-casing/kebab-case/tslint.json
@@ -0,0 +1,5 @@
{
"rules": {
"file-name-casing": [true, "kebab-case"]
}
}
@@ -0,0 +1,2 @@

~nil [File name must be PascalCase]
5 changes: 5 additions & 0 deletions test/rules/file-name-casing/pascal-case/tslint.json
@@ -0,0 +1,5 @@
{
"rules": {
"file-name-casing": [true, "pascal-case"]
}
}
2 changes: 0 additions & 2 deletions test/rules/file-name-casing/snake-case/no-camel-case.ts.lint

This file was deleted.