core/quality/eslint/local-camelcase.eslint.rule.ts

core/quality/eslint/local-camelcase.eslint.rule.ts is a file in GovLab Quality. 123 lines of code and 28 definitions.

import type { Rule, Scope } from "eslint";
import { isDigit, isLowerAlpha } from "@govlab/constants";
import { govlabMeta } from "#core/factories/eslint.factory";

const LOCAL_SCOPES = new Set(["function", "block", "for", "catch"]);
const RENAMEABLE_DEFS = new Set(["Variable", "Parameter"]);

const isRecord = function isRecord(value: unknown): value is Record<string, unknown> {
    return value !== null && typeof value === "object";
};

const isSnakeChar = function isSnakeChar(ch: string): boolean {
    return isLowerAlpha(ch) || isDigit(ch) || ch === "_";
};

const hasSnakeShape = function hasSnakeShape(name: string): boolean {
    return name.includes("_") && !name.startsWith("_") && !name.endsWith("_") && !name.includes("__");
};

const isSnakeCase = function isSnakeCase(name: string): boolean {
    if (!hasSnakeShape(name)) {
        return false;
    }
    for (const ch of name) {
        if (!isSnakeChar(ch)) {
            return false;
        }
    }
    return true;
};

const toCamelCase = function toCamelCase(name: string): string {
    const parts = name.split("_");
    let out = parts[0] ?? "";
    for (let i = 1; i < parts.length; i += 1) {
        const part = parts[i];
        if (typeof part === "string" && part.length > 0) {
            out += (part[0] ?? "").toUpperCase() + part.slice(1);
        }
    }
    return out;
};

const isShorthandProperty = function isShorthandProperty(value: unknown): boolean {
    if (!isRecord(value)) {
        return false;
    }
    const { parent } = value;
    return isRecord(parent) && parent["type"] === "Property" && parent["shorthand"] === true;
};

const carriesExternalKey = function carriesExternalKey(variable: Scope.Variable): boolean {
    return variable.identifiers.some(isShorthandProperty);
};

const isRenameableDefinition = function isRenameableDefinition(variable: Scope.Variable): boolean {
    return (
        variable.identifiers.length > 0 &&
        variable.defs.length > 0 &&
        variable.defs.every((def) => RENAMEABLE_DEFS.has(def.type))
    );
};

const isRenameable = function isRenameable(variable: Scope.Variable, taken: Set<string>, camel: string): boolean {
    if (!isRenameableDefinition(variable)) {
        return false;
    }
    return camel !== variable.name && !taken.has(camel) && !carriesExternalKey(variable);
};

const rangeStart = function rangeStart(node: { range?: [number, number] | undefined }): number {
    return node.range?.[0] ?? 0;
};

const collectStarts = function collectStarts(variable: Scope.Variable): number[] {
    const starts = new Set<number>();
    for (const identifier of variable.identifiers) {
        starts.add(rangeStart(identifier));
    }
    for (const reference of variable.references) {
        starts.add(rangeStart(reference.identifier));
    }
    return [...starts];
};

const reportVariable = function reportVariable(
    context: Rule.RuleContext,
    variable: Scope.Variable,
    camel: string,
): void {
    const [firstId] = variable.identifiers;
    if (!firstId) {
        return;
    }
    const starts = collectStarts(variable);
    const nameLength = variable.name.length;
    context.report({
        data: { camel, name: variable.name },
        fix: (fixer): Rule.Fix[] =>
            starts.map((start): Rule.Fix => fixer.replaceTextRange([start, start + nameLength], camel)),
        messageId: "camelCase",
        node: firstId,
    });
};

const checkScope = function checkScope(context: Rule.RuleContext, scope: Scope.Scope): void {
    if (LOCAL_SCOPES.has(scope.type)) {
        const taken = new Set(scope.variables.map((variable) => variable.name));
        for (const variable of scope.variables) {
            const camel = toCamelCase(variable.name);
            if (isSnakeCase(variable.name) && isRenameable(variable, taken, camel)) {
                reportVariable(context, variable, camel);
            }
        }
    }
    for (const child of scope.childScopes) {
        checkScope(context, child);
    }
};

export default {
    create(context: Rule.RuleContext): Rule.RuleListener {
        const onExit = (node: Rule.Node): void => {
            checkScope(context, context.sourceCode.getScope(node));
        };
        const handlers: [string, (node: Rule.Node) => void][] = [["Program:exit", onExit]];
        return Object.fromEntries(handlers);
    },
    meta: govlabMeta({
        canonical: ["idiomatic-preference"],
        description:
            "Normalize file-local identifiers (variables, parameters) to camelCase; cross-file and destructured-key bindings are left for manual judgment",
        fixable: "code",
        messages: { camelCase: "Local identifier '{{name}}' should be camelCase '{{camel}}'." },
        ruleId: "local_camelcase",
        type: "suggestion",
    }),
} satisfies Rule.RuleModule;