Skip to content

Commit

Permalink
feat(type-declaration-immutability): create rule
Browse files Browse the repository at this point in the history
  • Loading branch information
RebeccaStevens committed Sep 19, 2022
1 parent 01e7eb4 commit 7838fd2
Show file tree
Hide file tree
Showing 18 changed files with 1,177 additions and 13 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"@typescript-eslint/utils": "^5.10.2",
"deepmerge-ts": "^4.0.3",
"escape-string-regexp": "^4.0.0",
"is-immutable-type": "^0.0.7",
"semver": "^7.3.7"
},
"devDependencies": {
Expand Down
1 change: 1 addition & 0 deletions src/configs/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const config: Linter.Config = {
"functional/prefer-readonly-type": "error",
"functional/prefer-tacit": ["error", { assumeTypes: false }],
"functional/no-return-void": "error",
"functional/type-declaration-immutability": "error",
},
},
],
Expand Down
1 change: 1 addition & 0 deletions src/configs/no-mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const config: Linter.Config = {
rules: {
"functional/no-method-signature": "warn",
"functional/prefer-readonly-type": "error",
"functional/type-declaration-immutability": "error",
},
},
],
Expand Down
2 changes: 2 additions & 0 deletions src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as noThrowStatement from "./no-throw-statement";
import * as noTryStatement from "./no-try-statement";
import * as preferReadonlyTypes from "./prefer-readonly-type";
import * as preferTacit from "./prefer-tacit";
import * as typeDeclarationImmutability from "./type-declaration-immutability";

/**
* All of the custom rules.
Expand All @@ -35,4 +36,5 @@ export const rules = {
[noTryStatement.name]: noTryStatement.rule,
[preferReadonlyTypes.name]: preferReadonlyTypes.rule,
[preferTacit.name]: preferTacit.rule,
[typeDeclarationImmutability.name]: typeDeclarationImmutability.rule,
};
334 changes: 334 additions & 0 deletions src/rules/type-declaration-immutability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,334 @@
import type { ESLintUtils, TSESLint, TSESTree } from "@typescript-eslint/utils";
import { deepmerge } from "deepmerge-ts";
import { Immutability } from "is-immutable-type";
import type { JSONSchema4 } from "json-schema";
import type { ReadonlyDeep } from "type-fest";

import type { IgnorePatternOption } from "~/common/ignore-options";
import {
shouldIgnorePattern,
ignorePatternOptionSchema,
} from "~/common/ignore-options";
import { getNodeIdentifierTexts } from "~/util/misc";
import type { ESTypeDeclaration } from "~/util/node-types";
import type { RuleResult } from "~/util/rule";
import { getTypeImmutabilityOfNode, createRule } from "~/util/rule";
import { isReadonlyArray, isTSInterfaceDeclaration } from "~/util/typeguard";

/**
* The name of this rule.
*/
export const name = "type-declaration-immutability" as const;

/**
* How the actual immutability should be compared to the given immutability.
*/
export enum RuleEnforcementComparator {
Less = -2,
AtMost = -1,
Exactly = 0,
AtLeast = 1,
More = 2,
}

/**
* The options this rule can take.
*/
type Options = ReadonlyDeep<
[
IgnorePatternOption & {
rules?: Array<{
identifier: string | string[];
immutability: Exclude<
Immutability | keyof typeof Immutability,
"Unknown"
>;
comparator?:
| RuleEnforcementComparator
| keyof typeof RuleEnforcementComparator;
}>;
ignoreInterfaces: boolean;
}
]
>;

/**
* The schema for the rule options.
*/
const schema: JSONSchema4 = [
{
type: "object",
properties: deepmerge(ignorePatternOptionSchema, {
rules: {
type: "array",
items: {
type: "object",
properties: {
identifier: {
type: ["string", "array"],
items: {
type: "string",
},
},
immutability: {
type: ["string", "number"],
enum: Object.values(Immutability).filter(
(i) =>
i !== Immutability.Unknown &&
i !== Immutability[Immutability.Unknown]
),
},
comparator: {
type: ["string", "number"],
enum: Object.values(RuleEnforcementComparator),
},
},
required: ["identifier", "immutability"],
additionalProperties: false,
},
},
ignoreInterfaces: {
type: "boolean",
},
}),
additionalProperties: false,
},
];

/**
* The default options for the rule.
*/
const defaultOptions: Options = [
{
ignoreInterfaces: false,
},
];

/**
* The possible error messages.
*/
const errorMessages = {
Less: 'This type is declare to have an immutability less than "{{ expected }}" (actual: "{{ actual }}").',
AtLeast:
'This type is declare to have an immutability of at least "{{ expected }}" (actual: "{{ actual }}").',
Exactly:
'This type is declare to have an immutability of exactly "{{ expected }}" (actual: "{{ actual }}").',
AtMost:
'This type is declare to have an immutability of at most "{{ expected }}" (actual: "{{ actual }}").',
More: 'This type is declare to have an immutability more than "{{ expected }}" (actual: "{{ actual }}").',
} as const;

/**
* The meta data for this rule.
*/
const meta: ESLintUtils.NamedCreateRuleMeta<keyof typeof errorMessages> = {
type: "suggestion",
docs: {
description: "Enforce the immutability of types based on patterns.",
recommended: "error",
},
messages: errorMessages,
schema,
};

/**
* A rule given by the user after being upgraded.
*/
export type ImmutabilityRule = {
identifiers: ReadonlyArray<RegExp>;
immutability: Immutability;
comparator: RuleEnforcementComparator;
};

/**
* Get the default immutability rules.
*/
function getDefaultImmutabilityRules(): ImmutabilityRule[] {
return [
{
identifiers: [/^I?Immutable.+/u],
immutability: Immutability.Immutable,
comparator: RuleEnforcementComparator.AtLeast,
},
{
identifiers: [/^I?ReadonlyDeep.+/u],
immutability: Immutability.ReadonlyDeep,
comparator: RuleEnforcementComparator.AtLeast,
},
{
identifiers: [/^I?Readonly.+/u],
immutability: Immutability.ReadonlyShallow,
comparator: RuleEnforcementComparator.AtLeast,
},
{
identifiers: [/^I?Mutable.+/u],
immutability: Immutability.Mutable,
comparator: RuleEnforcementComparator.AtMost,
},
];
}

/**
* Get all the rules that were given and upgrade them.
*/
function getRules(options: Options): ImmutabilityRule[] {
const [optionsObject] = options;
const { rules: rulesOptions } = optionsObject;

if (rulesOptions === undefined) {
return getDefaultImmutabilityRules();
}

return rulesOptions.map((rule): ImmutabilityRule => {
const identifiers = isReadonlyArray(rule.identifier)
? rule.identifier.map((id) => new RegExp(id, "u"))
: [new RegExp(rule.identifier, "u")];

const immutability =
typeof rule.immutability === "string"
? Immutability[rule.immutability]
: rule.immutability;

const comparator =
rule.comparator === undefined
? RuleEnforcementComparator.AtLeast
: typeof rule.comparator === "string"
? RuleEnforcementComparator[rule.comparator]
: rule.comparator;

return {
identifiers,
immutability,
comparator,
};
});
}

/**
* Find the first rule to apply to the given node.
*/
export function getRuleToApply(
node: ReadonlyDeep<TSESTree.Node>,
context: ReadonlyDeep<
TSESLint.RuleContext<keyof typeof errorMessages, Options>
>,
options: Options
): ImmutabilityRule | undefined {
const rules = getRules(options);
if (rules.length === 0) {
return undefined;
}

const texts = getNodeIdentifierTexts(node, context);

if (texts.length === 0) {
return undefined;
}

return rules.find((rule) =>
rule.identifiers.some((pattern) => texts.some((text) => pattern.test(text)))
);
}

/**
* Compare the actual immutability to the expected immutability.
*/
export function compareImmutability(
rule: ReadonlyDeep<ImmutabilityRule>,
actual: Immutability
) {
switch (rule.comparator) {
case RuleEnforcementComparator.Less:
return actual < rule.immutability;
case RuleEnforcementComparator.AtMost:
return actual <= rule.immutability;
case RuleEnforcementComparator.Exactly:
return actual === rule.immutability;
case RuleEnforcementComparator.AtLeast:
return actual >= rule.immutability;
case RuleEnforcementComparator.More:
return actual > rule.immutability;
}
}

/**
* Get the results.
*/
function getResults(
node: ReadonlyDeep<ESTypeDeclaration>,
context: ReadonlyDeep<
TSESLint.RuleContext<keyof typeof errorMessages, Options>
>,
rule: ImmutabilityRule,
immutability: Immutability
): RuleResult<keyof typeof errorMessages, Options> {
const valid = compareImmutability(rule, immutability);
if (valid) {
return {
context,
descriptors: [],
};
}

return {
context,
descriptors: [
{
node: node.id,
messageId: RuleEnforcementComparator[
rule.comparator
] as keyof typeof RuleEnforcementComparator,
data: {
actual: Immutability[immutability],
expected: Immutability[rule.immutability],
},
},
],
};
}

/**
* Check if the given Interface or Type Alias violates this rule.
*/
function checkTypeDeclaration(
node: ReadonlyDeep<ESTypeDeclaration>,
context: ReadonlyDeep<
TSESLint.RuleContext<keyof typeof errorMessages, Options>
>,
options: Options
): RuleResult<keyof typeof errorMessages, Options> {
const [optionsObject] = options;
const { ignoreInterfaces } = optionsObject;
if (
shouldIgnorePattern(node, context, optionsObject) ||
(ignoreInterfaces && isTSInterfaceDeclaration(node))
) {
return {
context,
descriptors: [],
};
}

const rule = getRuleToApply(node, context, options);
if (rule === undefined) {
return {
context,
descriptors: [],
};
}

const immutability = getTypeImmutabilityOfNode(node, context);

return getResults(node, context, rule, immutability);
}

// Create the rule.
export const rule = createRule<keyof typeof errorMessages, Options>(
name,
meta,
defaultOptions,
{
TSTypeAliasDeclaration: checkTypeDeclaration,
TSInterfaceDeclaration: checkTypeDeclaration,
}
);
Loading

0 comments on commit 7838fd2

Please sign in to comment.