Skip to content

Commit

Permalink
part: set up overridable options (#812)
Browse files Browse the repository at this point in the history
  • Loading branch information
RebeccaStevens committed Aug 5, 2024
1 parent 50f024c commit 4eca1bb
Show file tree
Hide file tree
Showing 8 changed files with 473 additions and 2 deletions.
2 changes: 1 addition & 1 deletion knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "node_modules/knip/schema-jsonc.json",
"entry": ["src/index.ts!", "tests/**/*.test.ts"],
"project": ["src/**/*.ts!", "tests/**/*.{js,ts}"],
"ignore": ["lib/**", "tests/fixture/file.ts"],
"ignore": ["lib/**", "tests/fixture/file.ts", "src/utils/schemas.ts"],
"ignoreDependencies": [
// Unknown reason for issue.
"@vitest/coverage-v8",
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@
"deepmerge-ts": "^7.1.0",
"escape-string-regexp": "^5.0.0",
"is-immutable-type": "^5.0.0",
"ts-api-utils": "^1.3.0"
"ts-api-utils": "^1.3.0",
"ts-declaration-location": "^1.0.3"
},
"devDependencies": {
"@babel/eslint-parser": "7.25.1",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/options/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./ignore";
export * from "./overrides";
185 changes: 185 additions & 0 deletions src/options/overrides.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import assert from "node:assert/strict";

import type { TSESTree } from "@typescript-eslint/utils";
import type { RuleContext } from "@typescript-eslint/utils/ts-eslint";
import { deepmerge } from "deepmerge-ts";
import typeMatchesSpecifier, {
type TypeDeclarationSpecifier,
} from "ts-declaration-location";
import type { Program, Type, TypeNode } from "typescript";

import { getTypeDataOfNode } from "#/utils/rule";
import {
type RawTypeSpecifier,
type TypeSpecifier,
typeMatchesPattern,
} from "#/utils/type-specifier";

/**
* Options that can be overridden.
*/
export type OverridableOptions<CoreOptions> = CoreOptions & {
overrides?: Array<
{
specifiers: TypeSpecifier | TypeSpecifier[];
} & (
| {
options: CoreOptions;
inherit?: boolean;
disable?: false;
}
| {
disable: true;
}
)
>;
};

export type RawOverridableOptions<CoreOptions> = CoreOptions & {

Check warning on line 38 in src/options/overrides.ts

View workflow job for this annotation

GitHub Actions / lint_js

Missing JSDoc comment
overrides?: Array<{
specifiers?: RawTypeSpecifier | RawTypeSpecifier[];
options?: CoreOptions;
inherit?: boolean;
disable?: boolean;
}>;
};

export function upgradeRawOverridableOptions<CoreOptions>(

Check warning on line 47 in src/options/overrides.ts

View workflow job for this annotation

GitHub Actions / lint_js

Missing JSDoc comment
raw: Readonly<RawOverridableOptions<CoreOptions>>,
): OverridableOptions<CoreOptions> {
return {
...raw,
overrides:
raw.overrides?.map((override) => ({
...override,
specifiers:
override.specifiers === undefined
? []
: Array.isArray(override.specifiers)
? override.specifiers.map(upgradeRawTypeSpecifier)
: [upgradeRawTypeSpecifier(override.specifiers)],
})) ?? [],
} as OverridableOptions<CoreOptions>;
}

function upgradeRawTypeSpecifier(raw: RawTypeSpecifier): TypeSpecifier {
const { ignoreName, ignorePattern, name, pattern, ...rest } = raw;

const names = name === undefined ? [] : Array.isArray(name) ? name : [name];

const patterns = (
pattern === undefined ? [] : Array.isArray(pattern) ? pattern : [pattern]
).map((p) => new RegExp(p, "u"));

const ignoreNames =
ignoreName === undefined
? []
: Array.isArray(ignoreName)
? ignoreName
: [ignoreName];

const ignorePatterns = (
ignorePattern === undefined
? []
: Array.isArray(ignorePattern)
? ignorePattern
: [ignorePattern]
).map((p) => new RegExp(p, "u"));

const include = [...names, ...patterns];
const exclude = [...ignoreNames, ...ignorePatterns];

return {
...rest,
include,
exclude,
};
}

/**
* Get the core options to use, taking into account overrides.
*/
export function getCoreOptions<
CoreOptions extends object,
Options extends Readonly<OverridableOptions<CoreOptions>>,
>(
node: TSESTree.Node,
context: Readonly<RuleContext<string, unknown[]>>,
options: Readonly<Options>,
): CoreOptions | null {
const program = context.sourceCode.parserServices?.program ?? undefined;
if (program === undefined) {
return options;
}

const [type, typeNode] = getTypeDataOfNode(node, context);
return getCoreOptionsForType(type, typeNode, context, options);
}

export function getCoreOptionsForType<

Check warning on line 119 in src/options/overrides.ts

View workflow job for this annotation

GitHub Actions / lint_js

Missing JSDoc comment
CoreOptions extends object,
Options extends Readonly<OverridableOptions<CoreOptions>>,
>(
type: Type,
typeNode: TypeNode | null,
context: Readonly<RuleContext<string, unknown[]>>,
options: Readonly<Options>,
): CoreOptions | null {
const program = context.sourceCode.parserServices?.program ?? undefined;
if (program === undefined) {
return options;
}

const found = options.overrides?.find((override) =>
(Array.isArray(override.specifiers)
? override.specifiers
: [override.specifiers]
).some(
(specifier) =>
typeMatchesSpecifierDeep(program, specifier, type) &&
(specifier.include === undefined ||
specifier.include.length === 0 ||
typeMatchesPattern(
program,
type,
typeNode,
specifier.include,
specifier.exclude,
)),
),
);

if (found !== undefined) {
if (found.disable === true) {
return null;
}
if (found.inherit !== false) {
return deepmerge(options, found.options) as CoreOptions;
}
return found.options;
}

return options;
}

function typeMatchesSpecifierDeep(
program: Program,
specifier: TypeDeclarationSpecifier,
type: Type,
) {
const m_stack = [type];
// eslint-disable-next-line functional/no-loop-statements -- best to do this iteratively.
while (m_stack.length > 0) {
const t = m_stack.pop() ?? assert.fail();

if (typeMatchesSpecifier(program, specifier, t)) {
return true;
}

if (t.aliasTypeArguments !== undefined) {
m_stack.push(...t.aliasTypeArguments);
}
}

return false;
}
39 changes: 39 additions & 0 deletions src/utils/rule.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import assert from "node:assert/strict";

import type { TSESTree } from "@typescript-eslint/utils";
import {
type NamedCreateRuleMeta,
Expand All @@ -23,6 +25,8 @@ import { getImmutabilityOverrides } from "#/settings";
import { __VERSION__ } from "#/utils/constants";
import type { ESFunction } from "#/utils/node-types";

import { typeMatchesPattern } from "./type-specifier";

type Docs = {
/**
* Used for creating category configs and splitting the README rules list into sub-lists.
Expand Down Expand Up @@ -188,12 +192,46 @@ export function getTypeOfNode<Context extends RuleContext<string, BaseOptions>>(
node: TSESTree.Node,
context: Context,
): Type {
assert(typescript !== undefined);

const { esTreeNodeToTSNodeMap } = getParserServices(context);

const tsNode = esTreeNodeToTSNodeMap.get(node);
return getTypeOfTSNode(tsNode, context);
}

/**
* Get the type of the the given node.
*/
export function getTypeNodeOfNode<
Context extends RuleContext<string, BaseOptions>,
>(node: TSESTree.Node, context: Context): TypeNode | null {
assert(typescript !== undefined);

const { esTreeNodeToTSNodeMap } = getParserServices(context);

const tsNode = esTreeNodeToTSNodeMap.get(node) as TSNode & {
type?: TypeNode;
};
return tsNode.type ?? null;
}

/**
* Get the type of the the given node.
*/
export function getTypeDataOfNode<
Context extends RuleContext<string, BaseOptions>,
>(node: TSESTree.Node, context: Context): [Type, TypeNode | null] {
assert(typescript !== undefined);

const { esTreeNodeToTSNodeMap } = getParserServices(context);

const tsNode = esTreeNodeToTSNodeMap.get(node) as TSNode & {
type?: TypeNode;
};
return [getTypeOfTSNode(tsNode, context), tsNode.type ?? null];
}

/**
* Get the type of the the given ts node.
*/
Expand Down Expand Up @@ -284,6 +322,7 @@ export function getTypeImmutabilityOfNode<
// Don't use the global cache in testing environments as it may cause errors when switching between different config options.
process.env["NODE_ENV"] !== "test",
maxImmutability,
typeMatchesPattern,
);
}

Expand Down
81 changes: 81 additions & 0 deletions src/utils/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type {
JSONSchema4,
JSONSchema4ObjectSchema,
} from "@typescript-eslint/utils/json-schema";

const typeSpecifierPatternSchemaProperties: JSONSchema4ObjectSchema["properties"] =
{
name: schemaInstanceOrInstanceArray({
type: "string",
}),
pattern: schemaInstanceOrInstanceArray({
type: "string",
}),
ignoreName: schemaInstanceOrInstanceArray({
type: "string",
}),
ignorePattern: schemaInstanceOrInstanceArray({
type: "string",
}),
};

const typeSpecifierSchema: JSONSchema4 = {
oneOf: [
{
type: "object",
properties: {
...typeSpecifierPatternSchemaProperties,
from: {
type: "string",
enum: ["file"],
},
path: {
type: "string",
},
},
additionalProperties: false,
},
{
type: "object",
properties: {
...typeSpecifierPatternSchemaProperties,
from: {
type: "string",
enum: ["lib"],
},
},
additionalProperties: false,
},
{
type: "object",
properties: {
...typeSpecifierPatternSchemaProperties,
from: {
type: "string",
enum: ["package"],
},
package: {
type: "string",
},
},
additionalProperties: false,
},
],
};

export const typeSpecifiersSchema: JSONSchema4 =
schemaInstanceOrInstanceArray(typeSpecifierSchema);

export function schemaInstanceOrInstanceArray(

Check warning on line 69 in src/utils/schemas.ts

View workflow job for this annotation

GitHub Actions / lint_js

Missing JSDoc comment
items: JSONSchema4,
): NonNullable<JSONSchema4ObjectSchema["properties"]>[string] {
return {
oneOf: [
items,
{
type: "array",
items,
},
],
};
}
Loading

0 comments on commit 4eca1bb

Please sign in to comment.