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
88 changes: 73 additions & 15 deletions src/rules/fileNameCasingRule.ts
Expand Up @@ -18,6 +18,7 @@
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";

Expand All @@ -40,6 +41,8 @@ type Validator<T extends Config> = (sourceFile: ts.SourceFile, casing: T) => Val

const rules = [Casing.CamelCase, Casing.PascalCase, Casing.KebabCase, Casing.SnakeCase];

const validCasingOptions = new Set(rules);

function isCorrectCasing(fileName: string, casing: Casing): boolean {
switch (casing) {
case Casing.CamelCase:
Expand All @@ -53,35 +56,78 @@ function isCorrectCasing(fileName: string, casing: Casing): boolean {
}
}

const validateWithRegexConfig: Validator<RegexConfig> = (sourceFile, casingConfig) => {
const fileName = path.parse(sourceFile.fileName).base;
const config = Object.keys(casingConfig).map(key => ({
casing: casingConfig[key],
regex: RegExp(key),
}));
const getValidRegExp = (regExpString: string): RegExp | undefined => {
try {
return RegExp(regExpString, "i");
} catch {
return undefined;
}
};

const match = config.find(c => c.regex.test(fileName));
const validateWithRegexConfig: Validator<RegexConfig> = (sourceFile, casingConfig) => {
const fileBaseName = path.parse(sourceFile.fileName).base;

if (match === undefined) {
const fileNameMatches = Object.keys(casingConfig);
if (fileNameMatches.length === 0) {
Rule.showWarning(`At least one file name match must be provided`);
return undefined;
}

const normalizedFileName = fileName.replace(match.regex, "");
for (const rawMatcher of fileNameMatches) {
const regex = getValidRegExp(rawMatcher);
if (regex === undefined) {
Rule.showWarning(`Invalid regular expression provided: ${rawMatcher}`);
continue;
}

const casing = casingConfig[rawMatcher];
if (!validCasingOptions.has(casing)) {
Rule.showWarning(`Unexpected casing option provided: ${casing}`);
continue;
}

if (!regex.test(fileBaseName)) {
continue;
}

return isCorrectCasing(fileBaseName, casing) ? undefined : casing;
}

return isCorrectCasing(normalizedFileName, match.casing) ? undefined : match.casing;
return undefined;
};

const validateWithSimpleConfig: Validator<SimpleConfig> = (sourceFile, casingConfig) => {
if (!validCasingOptions.has(casingConfig)) {
Rule.showWarning(`Unexpected casing option provided: ${casingConfig}`);
return undefined;
}

const fileName = path.parse(sourceFile.fileName).name;
const isValid = isCorrectCasing(fileName, casingConfig);

return isValid ? undefined : casingConfig;
};

const validate = (sourceFile: ts.SourceFile, casingConfig: Config): ValidationResult =>
typeof casingConfig === "string"
? validateWithSimpleConfig(sourceFile, casingConfig)
: validateWithRegexConfig(sourceFile, casingConfig);
const validate = (
sourceFile: ts.SourceFile,
casingConfig: Config | undefined,
): ValidationResult | undefined => {
if (casingConfig === undefined) {
Rule.showWarning("Provide a rule option as string or object");
return undefined;
}

if (typeof casingConfig === "string") {
return validateWithSimpleConfig(sourceFile, casingConfig);
}

if (typeof casingConfig === "object") {
return validateWithRegexConfig(sourceFile, casingConfig);
}

Rule.showWarning("Received unexpected rule option");
return undefined;
};

export class Rule extends Lint.Rules.AbstractRule {
/* tslint:disable:object-literal-sort-keys */
Expand Down Expand Up @@ -139,13 +185,25 @@ export class Rule extends Lint.Rules.AbstractRule {
".ts": Casing.CamelCase,
},
],
[
true,
{
".style.ts": Casing.KebabCase,
".tsx": Casing.PascalCase,
".*": Casing.CamelCase,
},
],
],
hasFix: false,
type: "style",
typescriptOnly: false,
};
/* tslint:enable:object-literal-sort-keys */

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

private static FAILURE_STRING(expectedCasing: Casing): string {
return `File name must be ${Rule.stylizedNameForCasing(expectedCasing)}`;
}
Expand All @@ -168,7 +226,7 @@ export class Rule extends Lint.Rules.AbstractRule {
return [];
}

const casingConfig = this.ruleArguments[0] as Config;
const casingConfig = this.ruleArguments[0] as Config | undefined;
const validation = validate(sourceFile, casingConfig);

return validation === undefined
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.