UNPKG

@slippy-lint/slippy

Version:

A simple but powerful linter for Solidity

899 lines (807 loc) 24.5 kB
/* This file includes code adapted from the `naming-convention` rule and related utilities in typescript-eslint: https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/src/rules/naming-convention.ts The original code is licensed under the MIT License: https://github.com/typescript-eslint/typescript-eslint/blob/main/LICENSE Significant parts of this file are derived from that source, with modifications for Solidity. */ import { File as SlangFile } from "@nomicfoundation/slang/compilation"; import { LintResult, RuleContext, RuleDefinition, RuleWithConfig, } from "./types.js"; import { assertNonterminalNode, Cursor, NonterminalKind, TerminalKind, TextRange, } from "@nomicfoundation/slang/cst"; import * as z from "zod"; import { AssertionError } from "../errors.js"; enum PredefinedFormats { camelCase = 1, strictCamelCase, PascalCase, StrictPascalCase, snake_case, UPPER_CASE, } type PredefinedFormatsString = keyof typeof PredefinedFormats; enum UnderscoreOptions { forbid = 1, allow, require, requireDouble, allowDouble, allowSingleOrDouble, } type UnderscoreOptionsString = keyof typeof UnderscoreOptions; export enum Selectors { contract = 1 << 0, interface = 1 << 1, library = 1 << 2, stateVariable = 1 << 3, function = 1 << 4, variable = 1 << 5, struct = 1 << 6, structMember = 1 << 7, enum = 1 << 8, enumMember = 1 << 9, parameter = 1 << 10, modifier = 1 << 11, event = 1 << 12, eventParameter = 1 << 13, userDefinedValueType = 1 << 14, error = 1 << 15, errorParameter = 1 << 16, mappingParameter = 1 << 17, } type SelectorsString = keyof typeof Selectors; export enum MetaSelectors { default = -1, typeLike = 0 | Selectors.contract | Selectors.interface | Selectors.library | Selectors.struct | Selectors.enum | Selectors.error | Selectors.event | Selectors.userDefinedValueType, variableLike = 0 | Selectors.stateVariable | Selectors.function | Selectors.variable | Selectors.structMember | Selectors.parameter | Selectors.modifier | Selectors.eventParameter | Selectors.errorParameter | Selectors.mappingParameter, } type MetaSelectorsString = keyof typeof MetaSelectors; type IndividualAndMetaSelectorsString = MetaSelectorsString | SelectorsString; enum Modifiers { constant = 1 << 0, immutable = 1 << 1, public = 1 << 2, internal = 1 << 3, private = 1 << 4, external = 1 << 5, view = 1 << 6, pure = 1 << 7, payable = 1 << 8, virtual = 1 << 9, override = 1 << 10, abstract = 1 << 11, noParameters = 1 << 12, hasParameters = 1 << 13, } type ModifiersString = keyof typeof Modifiers; interface MatchRegex { match: boolean; regex: string; } interface Selector { custom?: MatchRegex; filter?: string | MatchRegex; // format options format: PredefinedFormatsString[] | null; leadingUnderscore?: UnderscoreOptionsString; modifiers?: ModifiersString[]; // selector options selector: | IndividualAndMetaSelectorsString | IndividualAndMetaSelectorsString[]; trailingUnderscore?: UnderscoreOptionsString; } interface NormalizedMatchRegex { match: boolean; regex: RegExp; } interface NormalizedSelector { custom: NormalizedMatchRegex | null; filter: NormalizedMatchRegex | null; // format options format: PredefinedFormats[] | null; leadingUnderscore: UnderscoreOptions | null; modifiers: Modifiers[] | null; // calculated ordering weight based on modifiers modifierWeight: number; // selector options selector: MetaSelectors | Selectors; trailingUnderscore: UnderscoreOptions | null; } type NamingConventionUserConfig = Selector[]; type NamingConventionNormalizedConfig = NormalizedSelector[]; const DEFAULT_CONFIG: NamingConventionUserConfig = [ { selector: "default", format: ["camelCase"], leadingUnderscore: "allow", trailingUnderscore: "allow", }, { selector: "typeLike", format: ["PascalCase"], }, { selector: "enumMember", format: ["PascalCase"], }, ]; type PartialRecord<TKey extends PropertyKey, TValue> = { [key in TKey]?: TValue; }; type NodeMetadata = { selector: Selectors; extractNames: (cursor: Cursor) => IdentifierWithTextRange[]; }; interface IdentifierWithTextRange { name: string; textRange: TextRange; } const extractFirstIdentifier = (cursor: Cursor): IdentifierWithTextRange[] => { if (!cursor.goToNextTerminalWithKind(TerminalKind.Identifier)) { return []; } return [ { name: cursor.node.unparse(), textRange: cursor.textRange, }, ]; }; const extractIdentifierAfter = ( cursor: Cursor, nonterminalToIgnore: NonterminalKind, ): IdentifierWithTextRange[] => { if (!cursor.goToNextNonterminalWithKind(nonterminalToIgnore)) { return []; } if (!cursor.goToNextSibling()) { return []; } return extractFirstIdentifier(cursor); }; const extractAllIdentifiers = (cursor: Cursor): IdentifierWithTextRange[] => { const identifiers: IdentifierWithTextRange[] = []; while (cursor.goToNextTerminalWithKind(TerminalKind.Identifier)) { identifiers.push({ name: cursor.node.unparse(), textRange: cursor.textRange, }); } return identifiers; }; const nonterminalKindToMetadata: PartialRecord<NonterminalKind, NodeMetadata> = { [NonterminalKind.ContractDefinition]: { selector: Selectors.contract, extractNames: extractFirstIdentifier, }, [NonterminalKind.InterfaceDefinition]: { selector: Selectors.interface, extractNames: extractFirstIdentifier, }, [NonterminalKind.LibraryDefinition]: { selector: Selectors.library, extractNames: extractFirstIdentifier, }, [NonterminalKind.StateVariableDefinition]: { selector: Selectors.stateVariable, extractNames: (cursor) => extractIdentifierAfter(cursor, NonterminalKind.TypeName), }, [NonterminalKind.FunctionDefinition]: { selector: Selectors.function, extractNames: extractFirstIdentifier, }, [NonterminalKind.VariableDeclarationStatement]: { selector: Selectors.variable, extractNames: (cursor) => extractIdentifierAfter(cursor, NonterminalKind.VariableDeclarationType), }, [NonterminalKind.TypedTupleMember]: { selector: Selectors.variable, extractNames: (cursor) => extractIdentifierAfter(cursor, NonterminalKind.TypeName), }, [NonterminalKind.StructDefinition]: { selector: Selectors.struct, extractNames: extractFirstIdentifier, }, [NonterminalKind.StructMember]: { selector: Selectors.structMember, extractNames: (cursor) => extractIdentifierAfter(cursor, NonterminalKind.TypeName), }, [NonterminalKind.EnumDefinition]: { selector: Selectors.enum, extractNames: extractFirstIdentifier, }, [NonterminalKind.EnumMembers]: { selector: Selectors.enumMember, extractNames: extractAllIdentifiers, }, [NonterminalKind.Parameter]: { selector: Selectors.parameter, extractNames: (cursor) => extractIdentifierAfter(cursor, NonterminalKind.TypeName), }, [NonterminalKind.ModifierDefinition]: { selector: Selectors.modifier, extractNames: extractFirstIdentifier, }, [NonterminalKind.EventDefinition]: { selector: Selectors.event, extractNames: extractFirstIdentifier, }, [NonterminalKind.EventParameter]: { selector: Selectors.eventParameter, extractNames: (cursor) => extractIdentifierAfter(cursor, NonterminalKind.TypeName), }, [NonterminalKind.UserDefinedValueTypeDefinition]: { selector: Selectors.userDefinedValueType, extractNames: extractFirstIdentifier, }, [NonterminalKind.ErrorDefinition]: { selector: Selectors.error, extractNames: extractFirstIdentifier, }, [NonterminalKind.ErrorParameter]: { selector: Selectors.errorParameter, extractNames: (cursor) => extractIdentifierAfter(cursor, NonterminalKind.TypeName), }, [NonterminalKind.MappingKey]: { selector: Selectors.mappingParameter, extractNames: (cursor) => extractIdentifierAfter(cursor, NonterminalKind.MappingKeyType), }, [NonterminalKind.MappingValue]: { selector: Selectors.mappingParameter, extractNames: (cursor) => extractIdentifierAfter(cursor, NonterminalKind.TypeName), }, }; export function normalizeConfig( userConfig: Config, ): NamingConventionNormalizedConfig { return userConfig.flatMap(normalizeOption).sort((a, b) => { if (a.selector === b.selector) { // in the event of the same selector, order by modifier weight // sort descending - the type modifiers are "more important" return b.modifierWeight - a.modifierWeight; } const aIsMeta = isMetaSelector(a.selector); const bIsMeta = isMetaSelector(b.selector); // non-meta selectors should go ahead of meta selectors if (aIsMeta && !bIsMeta) { return 1; } if (!aIsMeta && bIsMeta) { return -1; } return b.selector - a.selector; }); } const MatchRegexSchema = z.object({ match: z.boolean(), regex: z.string(), }); const SelectorsStringSchema = z.enum( Object.keys(Selectors).filter((x) => isNaN(Number(x))) as SelectorsString[], ); const MetaSelectorsStringSchema = z.enum( Object.keys(MetaSelectors).filter((x) => isNaN(Number(x)), ) as MetaSelectorsString[], ); const IndividualAndMetaSelectorsStringSchema = z.union([ SelectorsStringSchema, MetaSelectorsStringSchema, ]); const PredefinedFormatsStringSchema = z.enum( Object.keys(PredefinedFormats).filter((x) => isNaN(Number(x)), ) as PredefinedFormatsString[], ); const UnderscoreOptionsStringSchema = z.enum( Object.keys(UnderscoreOptions).filter((x) => isNaN(Number(x)), ) as UnderscoreOptionsString[], ); const ModifiersStringSchema = z.enum( Object.keys(Modifiers).filter((x) => isNaN(Number(x))) as ModifiersString[], ); export const Schema = z .array( z.object({ custom: z.optional(MatchRegexSchema), filter: z.optional(z.union([z.string(), MatchRegexSchema])), format: z.nullable(z.array(PredefinedFormatsStringSchema)), leadingUnderscore: z.optional(UnderscoreOptionsStringSchema), modifiers: z.optional(z.array(ModifiersStringSchema)), selector: z.union([ IndividualAndMetaSelectorsStringSchema, z.array(IndividualAndMetaSelectorsStringSchema), ]), trailingUnderscore: z.optional(UnderscoreOptionsStringSchema), }), ) .default(DEFAULT_CONFIG); type Config = z.infer<typeof Schema>; export const NamingConvention: RuleDefinition<Config> = { name: "naming-convention", recommended: false, parseConfig: (config: unknown) => { return Schema.parse(config); }, create: function (config) { return new NamingConventionRule(this.name, config); }, }; class NamingConventionRule implements RuleWithConfig<Config> { private normalizedConfig: NamingConventionNormalizedConfig; public constructor( public name: string, public config: Config, ) { this.normalizedConfig = normalizeConfig(config); } public run({ file }: RuleContext): LintResult[] { const results: LintResult[] = []; const cursor = file.createTreeCursor(); const definitionCursor = cursor.spawn(); while ( definitionCursor.goToNextNonterminalWithKinds( Object.keys(nonterminalKindToMetadata) as NonterminalKind[], ) ) { assertNonterminalNode(definitionCursor.node); const nonterminalMetadata = nonterminalKindToMetadata[definitionCursor.node.kind]; if (nonterminalMetadata === undefined) { throw new AssertionError( `Couldn't find selector for node kind: ${definitionCursor.node.kind}`, ); } const identifierCursor = definitionCursor.spawn(); const identifiersWithTextRange = nonterminalMetadata.extractNames(identifierCursor); for (const { name: originalName, textRange, } of identifiersWithTextRange) { for (const config of this.normalizedConfig) { if (!matchesSelector(config.selector, nonterminalMetadata.selector)) { continue; } if ( config.filter?.regex.test(originalName) !== config.filter?.match ) { // name does not match the filter continue; } if ( config.modifiers?.some( (modifier) => !hasModifier(definitionCursor.spawn(), modifier), ) === true ) { // does not have the required modifiers continue; } let name: string | null = originalName; let result; result = this.validateUnderscore( "leading", config, name, textRange, file, originalName, ); if (typeof result === "string") { name = result; } else { results.push(result); break; } result = this.validateUnderscore( "trailing", config, name, textRange, file, originalName, ); if (typeof result === "string") { name = result; } else { results.push(result); break; } const customLintResult = this.validateCustom( config, name, textRange, file, originalName, ); if (customLintResult !== undefined) { results.push(customLintResult); break; } const predefinedFormatLintResult = this.validatePredefinedFormat( config, name, textRange, file, originalName, ); if (predefinedFormatLintResult !== undefined) { results.push(predefinedFormatLintResult); break; } // if we reach here, the selector matches and the name is valid break; } } } return results; } private validateUnderscore( position: "leading" | "trailing", config: NormalizedSelector, name: string, textRange: TextRange, file: SlangFile, originalName: string, ): string | LintResult { const option = position === "leading" ? config.leadingUnderscore : config.trailingUnderscore; if (option === null) { return name; } const hasSingleUnderscore = position === "leading" ? (): boolean => name.startsWith("_") : (): boolean => name.endsWith("_"); const trimSingleUnderscore = position === "leading" ? (): string => name.slice(1) : (): string => name.slice(0, -1); const hasDoubleUnderscore = position === "leading" ? (): boolean => name.startsWith("__") : (): boolean => name.endsWith("__"); const trimDoubleUnderscore = position === "leading" ? (): string => name.slice(2) : (): string => name.slice(0, -2); switch (option) { // ALLOW - no conditions as the user doesn't care if it's there or not case UnderscoreOptions.allow: { if (hasSingleUnderscore()) { return trimSingleUnderscore(); } return name; } case UnderscoreOptions.allowDouble: { if (hasDoubleUnderscore()) { return trimDoubleUnderscore(); } return name; } case UnderscoreOptions.allowSingleOrDouble: { if (hasDoubleUnderscore()) { return trimDoubleUnderscore(); } if (hasSingleUnderscore()) { return trimSingleUnderscore(); } return name; } // FORBID case UnderscoreOptions.forbid: { if (hasSingleUnderscore()) { return { rule: this.name, sourceId: file.id, line: textRange.start.line, column: textRange.start.column, message: `'${originalName}' should not have a ${position} underscore`, }; } return name; } // REQUIRE case UnderscoreOptions.require: { if (!hasSingleUnderscore()) { return { rule: this.name, sourceId: file.id, line: textRange.start.line, column: textRange.start.column, message: `'${originalName}' should have a ${position} underscore`, }; } return trimSingleUnderscore(); } case UnderscoreOptions.requireDouble: { if (!hasDoubleUnderscore()) { return { rule: this.name, sourceId: file.id, line: textRange.start.line, column: textRange.start.column, message: `'${originalName}' should have a ${position} double underscore`, }; } return trimDoubleUnderscore(); } } } private validateCustom( config: NormalizedSelector, name: string, textRange: TextRange, file: SlangFile, originalName: string, ): LintResult | undefined { const custom = config.custom; if (!custom) { return; } const result = custom.regex.test(name); if (custom.match && result) { return; } if (!custom.match && !result) { return; } return { rule: this.name, sourceId: file.id, line: textRange.start.line, column: textRange.start.column, message: `'${originalName}' must ${custom.match ? "match" : "not match"} the RegExp ${custom.regex.toString()}`, }; } public validatePredefinedFormat( config: NormalizedSelector, name: string, textRange: TextRange, file: SlangFile, originalName: string, ): LintResult | undefined { const formats = config.format; if (formats === null || formats.length === 0) { return; } for (const format of formats) { const checker = PredefinedFormatToCheckFunction[format]; if (checker(name)) { return; } } const formatsString = formats?.map((f) => PredefinedFormats[f]).join(", "); return { rule: this.name, sourceId: file.id, line: textRange.start.line, column: textRange.start.column, message: `'${originalName}' does not match the required format(s): ${formatsString}`, }; } } function matchesSelector( selector: NormalizedSelector["selector"], kind: Selectors, ): boolean { return (selector & kind) !== 0 || selector === MetaSelectors.default; } function isMetaSelector( selector: IndividualAndMetaSelectorsString | MetaSelectors | Selectors, ): selector is MetaSelectorsString { return selector in MetaSelectors; } function normalizeOption(option: Selector): NormalizedSelector[] { let weight = 0; option.modifiers?.forEach((mod) => { weight |= Modifiers[mod]; }); // give selectors with a filter the _highest_ priority if (option.filter !== undefined) { weight |= 1 << 30; } const normalizedOption = { // format options custom: option.custom ? { match: option.custom.match, regex: new RegExp(option.custom.regex, "u"), } : null, filter: option.filter != null ? typeof option.filter === "string" ? { match: true, regex: new RegExp(option.filter, "u"), } : { match: option.filter.match, regex: new RegExp(option.filter.regex, "u"), } : null, format: option.format ? option.format.map((f) => PredefinedFormats[f]) : null, leadingUnderscore: option.leadingUnderscore != null ? UnderscoreOptions[option.leadingUnderscore] : null, modifiers: option.modifiers?.map((m) => Modifiers[m]) ?? null, trailingUnderscore: option.trailingUnderscore != null ? UnderscoreOptions[option.trailingUnderscore] : null, // calculated ordering weight based on modifiers modifierWeight: weight, }; const selectors = Array.isArray(option.selector) ? option.selector : [option.selector]; return selectors.map((selector) => ({ selector: isMetaSelector(selector) ? MetaSelectors[selector] : Selectors[selector], ...normalizedOption, })); } function hasKeyword(cursor: Cursor, keyword: TerminalKind): boolean { return cursor.goToNextTerminalWithKind(keyword); } const hasModifierMap: Record<Modifiers, (cursor: Cursor) => boolean> = { [Modifiers.constant]: (cursor) => hasKeyword(cursor, TerminalKind.ConstantKeyword), [Modifiers.immutable]: (cursor) => hasKeyword(cursor, TerminalKind.ImmutableKeyword), [Modifiers.public]: (cursor) => hasKeyword(cursor, TerminalKind.PublicKeyword), [Modifiers.internal]: (cursor) => hasKeyword(cursor, TerminalKind.InternalKeyword), [Modifiers.private]: (cursor) => hasKeyword(cursor, TerminalKind.PrivateKeyword), [Modifiers.external]: (cursor) => hasKeyword(cursor, TerminalKind.ExternalKeyword), [Modifiers.view]: (cursor) => hasKeyword(cursor, TerminalKind.ViewKeyword), [Modifiers.pure]: (cursor) => hasKeyword(cursor, TerminalKind.PureKeyword), [Modifiers.payable]: (cursor) => hasKeyword(cursor, TerminalKind.PayableKeyword), [Modifiers.virtual]: (cursor) => hasKeyword(cursor, TerminalKind.VirtualKeyword), [Modifiers.override]: (cursor) => hasKeyword(cursor, TerminalKind.OverrideKeyword), [Modifiers.abstract]: (cursor) => hasKeyword(cursor, TerminalKind.AbstractKeyword), [Modifiers.noParameters]: (cursor) => !cursor.goToNextNonterminalWithKind(NonterminalKind.Parameter), [Modifiers.hasParameters]: (cursor) => cursor.goToNextNonterminalWithKind(NonterminalKind.Parameter), }; function hasModifier(cursor: Cursor, modifier: Modifiers): boolean { return hasModifierMap[modifier](cursor); } const PredefinedFormatToCheckFunction: Readonly< Record<PredefinedFormats, (name: string) => boolean> > = { [PredefinedFormats.camelCase]: isCamelCase, [PredefinedFormats.PascalCase]: isPascalCase, [PredefinedFormats.snake_case]: isSnakeCase, [PredefinedFormats.strictCamelCase]: isStrictCamelCase, [PredefinedFormats.StrictPascalCase]: isStrictPascalCase, [PredefinedFormats.UPPER_CASE]: isUpperCase, }; function isPascalCase(name: string): boolean { return ( name.length === 0 || (name[0] === name[0].toUpperCase() && !name.includes("_")) ); } function isStrictPascalCase(name: string): boolean { return ( name.length === 0 || (name[0] === name[0].toUpperCase() && hasStrictCamelHumps(name, true)) ); } function isCamelCase(name: string): boolean { return ( name.length === 0 || (name[0] === name[0].toLowerCase() && !name.includes("_")) ); } function isStrictCamelCase(name: string): boolean { return ( name.length === 0 || (name[0] === name[0].toLowerCase() && hasStrictCamelHumps(name, false)) ); } function hasStrictCamelHumps(name: string, isUpper: boolean): boolean { function isUppercaseChar(char: string): boolean { return char === char.toUpperCase() && char !== char.toLowerCase(); } if (name.startsWith("_")) { return false; } for (let i = 1; i < name.length; ++i) { if (name[i] === "_") { return false; } if (isUpper === isUppercaseChar(name[i])) { if (isUpper) { return false; } } else { isUpper = !isUpper; } } return true; } function isSnakeCase(name: string): boolean { return ( name.length === 0 || (name === name.toLowerCase() && validateUnderscores(name)) ); } function isUpperCase(name: string): boolean { return ( name.length === 0 || (name === name.toUpperCase() && validateUnderscores(name)) ); } function validateUnderscores(name: string): boolean { if (name.startsWith("_")) { return false; } let wasUnderscore = false; for (let i = 1; i < name.length; ++i) { if (name[i] === "_") { if (wasUnderscore) { return false; } wasUnderscore = true; } else { wasUnderscore = false; } } return !wasUnderscore; }