Last active
April 10, 2025 08:22
-
-
Save reececomo/f75418edbb009d0339c79a90bbb4f110 to your computer and use it in GitHub Desktop.
ESLint rule for consistent bit masks (as inlineable const enums)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| bitmask-enums.js | |
| Custom rule for eslint-plugin-rulesdir | |
| @see https://www.npmjs.com/package/eslint-plugin-rulesdir | |
| Treats any enum with "mask" or "flag" in the name as a bit mask. | |
| Converts to an inlineable const enum. | |
| @example | |
| export enum ViewerFlag { | |
| Liked, | |
| Subscribed, | |
| Viewed, | |
| Blocked, | |
| All, | |
| AnyInteracted = ViewerFlag.LIKED | ViewerFlag.SUBSCRIBED, | |
| } | |
| // auto-fixes to: | |
| export const enum ViewerFlag { | |
| EMPTY = 0, | |
| LIKED = 1 << 0, | |
| SUBSCRIBED = 1 << 1, | |
| VIEWED = 1 << 2, | |
| BLOCKED = 1 << 3, | |
| ALL = 0x7FFFFFFF, | |
| ANY_INTERACTED = ViewerFlag.LIKED | ViewerFlag.SUBSCRIBED, | |
| } | |
| */ | |
| const RULE = "bitmask-enums"; | |
| const ENUM_TRIGGER_PATTERN = /(mask|flag)/i; | |
| const MAX_INT32 = 2147483647; | |
| const BIT_MASK_VALUE_NONE = 0x00000000; | |
| const BIT_MASK_VALUE_ALL = 0x7FFFFFFF; | |
| const BIT_MASK_VALUE_ALL_STRING = "0x7FFFFFFF"; | |
| const EVAL_COMPLEX_EXPRESSION = Symbol("eval_too_complex"); | |
| const BIT_MASK_BITS = [ | |
| // int8 | |
| 0b00000001, | |
| 0b00000010, | |
| 0b00000100, | |
| 0b00001000, | |
| 0b00010000, | |
| 0b00100000, | |
| 0b01000000, | |
| 0b10000000, | |
| // int16 | |
| 0b00000001_00000000, | |
| 0b00000010_00000000, | |
| 0b00000100_00000000, | |
| 0b00001000_00000000, | |
| 0b00010000_00000000, | |
| 0b00100000_00000000, | |
| 0b01000000_00000000, | |
| 0b10000000_00000000, | |
| // int24 | |
| 0b00000001_00000000_00000000, | |
| 0b00000010_00000000_00000000, | |
| 0b00000100_00000000_00000000, | |
| 0b00001000_00000000_00000000, | |
| 0b00010000_00000000_00000000, | |
| 0b00100000_00000000_00000000, | |
| 0b01000000_00000000_00000000, | |
| 0b10000000_00000000_00000000, | |
| // int32 | |
| 0b00000001_00000000_00000000_00000000, | |
| 0b00000010_00000000_00000000_00000000, | |
| 0b00000100_00000000_00000000_00000000, | |
| 0b00001000_00000000_00000000_00000000, | |
| 0b00010000_00000000_00000000_00000000, | |
| 0b00100000_00000000_00000000_00000000, | |
| 0b01000000_00000000_00000000_00000000, | |
| // 0b10000000_00000000_00000000_00000000, // ⚠️ int32 sign bit | |
| ] | |
| const SAFE_BIT_MASK_VALUES = [ | |
| BIT_MASK_VALUE_NONE, // NONE | |
| ...BIT_MASK_BITS, | |
| BIT_MASK_VALUE_ALL, // ALL (signed int32) | |
| ]; | |
| module.exports = { | |
| meta: { | |
| type: 'suggestion', | |
| docs: { | |
| description: `require numeric const enums to be bit masks (${RULE})`, | |
| url: "https://www.google.com/search?q=bit+masks", | |
| }, | |
| fixable: "code", | |
| schema: [], | |
| }, | |
| create(context) { | |
| return { | |
| ['TSEnumDeclaration']: function (node) | |
| { | |
| if ( !node.id.name.match( ENUM_TRIGGER_PATTERN ) ) | |
| { | |
| return; // apply only to masks | |
| } | |
| if ( !node.const ) | |
| { | |
| context.report({ | |
| node, | |
| message: `Bit mask enums must be const enums.`, | |
| fix(fixer) { | |
| return fixer.insertTextBeforeRange( node.range, `const ` ); | |
| } | |
| }); | |
| } | |
| const assignableBitsSet = new Set( BIT_MASK_BITS ); | |
| let previousValue = 0; | |
| // ---------------------------------------- | |
| // Check existing enum values | |
| // ---------------------------------------- | |
| // | |
| node.members.forEach( member => { | |
| const value = getEnumMemberValue( member, context ); | |
| if ( typeof value === "string" ) | |
| { | |
| return; // ✅ skip string enum values | |
| } | |
| // auto-fix: SNAKE_CASE members | |
| if ( member.id.name.toUpperCase() !== member.id.name ) | |
| { | |
| const upperSnakeName = member.id.name.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(); | |
| context.report({ | |
| node: member, | |
| message: `Bit mask enum member "${member.id.name}" should be named "${upperSnakeName}".`, | |
| fix(fixer) { | |
| return fixer.replaceText( member.id, upperSnakeName ); | |
| }, | |
| }); | |
| return; | |
| } | |
| if ( value === undefined ) | |
| { | |
| // skip enum members with no value (or that rely on complex expressions) | |
| return; | |
| } | |
| if ( member.id.name === "EMPTY" ) | |
| { | |
| if ( value !== 0 ) | |
| { | |
| context.report({ | |
| node: member, | |
| message: `Bit mask enum member "EMPTY" must equal 0.`, | |
| }); | |
| return; | |
| } | |
| } | |
| if ( member.id.name === "ALL" ) | |
| { | |
| if ( value !== BIT_MASK_VALUE_ALL ) | |
| { | |
| context.report({ | |
| node: member, | |
| message: `Bit mask enum member "ALL" must equal ${BIT_MASK_VALUE_ALL_STRING} (${BIT_MASK_VALUE_ALL}).`, | |
| fix(fixer) { | |
| if ( member.initializer ) { | |
| return fixer.replaceText( member.initializer, BIT_MASK_VALUE_ALL_STRING ); | |
| } | |
| else | |
| { | |
| const insertPos = member.range[0]; | |
| return fixer.insertTextAfterRange([insertPos, insertPos], ` = ${BIT_MASK_VALUE_ALL_STRING}`); | |
| } | |
| }, | |
| }); | |
| return; | |
| } | |
| } | |
| if ( value instanceof EvalError ) | |
| { | |
| // report eval failure | |
| context.report({ | |
| node: member, | |
| message: "Failed to parse / evaluate bit mask expression. Error: " + value, | |
| }); | |
| return; | |
| } | |
| if ( value === EVAL_COMPLEX_EXPRESSION ) | |
| { | |
| return; // ✅ ignore complex expressions | |
| } | |
| if ( typeof value !== "number" ) | |
| { | |
| context.report({ | |
| node: member, | |
| message: `Bit mask enum member "${member.id.name}" did not evaluate to a number: "${value}".`, | |
| }); | |
| return; | |
| } | |
| if ( value > MAX_INT32 ) | |
| { | |
| context.report({ | |
| node: member, | |
| message: `Unsafe bit mask enum member value. Hint: Use ${BIT_MASK_VALUE_ALL_STRING} (${BIT_MASK_VALUE_ALL}) to represent 'ALL'.`, | |
| }); | |
| return; | |
| } | |
| if ( ( value | 0 ) < 0 ) | |
| { | |
| context.report({ | |
| node: member, | |
| message: "Unsafe bit mask enum member value (signed 32-bit integer).", | |
| }); | |
| return; | |
| } | |
| if ( SAFE_BIT_MASK_VALUES.indexOf( value ) === -1 ) | |
| { | |
| context.report({ | |
| node: member, | |
| message: `Bit mask enum member contains illegal value "${value}" (expected "1 << 0" through "1 << 30").`, | |
| }); | |
| return; | |
| } | |
| if ( value < previousValue ) | |
| { | |
| context.report({ | |
| node: member, | |
| message: `Bit mask enum members are not in order.`, | |
| }); | |
| return; | |
| } | |
| // clear any values used already | |
| assignableBitsSet.delete( value ); | |
| if ( value !== BIT_MASK_VALUE_ALL ) | |
| { | |
| previousValue = value; | |
| } | |
| }); | |
| // ---------------------------------------- | |
| // Auto-assign missing values | |
| // ---------------------------------------- | |
| // | |
| const assignableBits = [ ...assignableBitsSet.values() ]; | |
| node.members.forEach( member => { | |
| if ( member.initializer?.value !== undefined ) return; | |
| if ( getEnumMemberValue( member, context ) !== undefined ) return; | |
| const bits = assignableBits.shift(); | |
| if ( bits === undefined ) | |
| { | |
| context.report({ | |
| node: member, | |
| message: `Bit mask enum has no more available values.`, | |
| }); | |
| return; | |
| } | |
| // no value: auto-assign | |
| context.report({ | |
| node: member, | |
| message: `Bit mask enum member "${member.id.name}" is missing a value.`, | |
| fix(fixer) { | |
| const exponent = 31 - Math.clz32( bits ); | |
| return fixer.insertTextAfter( member.id, ` = 1 << ${exponent}` ); | |
| } | |
| }); | |
| return; | |
| }); | |
| // ---------------------------------------- | |
| // Insert EMPTY value | |
| // ---------------------------------------- | |
| // | |
| if ( getEnumMemberValue( node.members[0], context ) !== 0 ) | |
| { | |
| context.report({ | |
| node: node, | |
| message: "Bit mask enum first value MUST be an EMPTY value (0).", | |
| fix(fixer) { | |
| const sourceCode = context.getSourceCode(); | |
| const openingBrace = sourceCode.getFirstToken(node, token => token.value === '{'); | |
| const braceIndex = openingBrace.range[0]; | |
| const insertPosition = braceIndex + 1; | |
| return fixer.insertTextBeforeRange([insertPosition, insertPosition], '\n EMPTY = 0,\n'); | |
| }, | |
| }); | |
| return; | |
| } | |
| // ---------------------------------------- | |
| // Auto-align values | |
| // ---------------------------------------- | |
| // | |
| const autoAlignMembers = node.members.filter( member => { | |
| return true | |
| && member.loc.start.line === member.loc.end.line | |
| && member.initializer | |
| ; | |
| }); | |
| const minimumIndent = Math.max( | |
| ...autoAlignMembers.map( member => member.id.name.length + 3 ) // include " = " | |
| ); | |
| const indentLevel = 2 * Math.ceil( minimumIndent / 2 ); | |
| autoAlignMembers.forEach( member => { | |
| const idx_endOfMemberName = member.id.range[1]; | |
| const idx_startOfInitializer = member.initializer.range[0]; | |
| const currentPaddingLength = idx_startOfInitializer - idx_endOfMemberName; | |
| const correctPaddingLength = indentLevel - member.id.name.length; | |
| if ( correctPaddingLength !== currentPaddingLength ) | |
| { | |
| context.report({ | |
| node: member, | |
| message: 'Bit mask enum member is not correctly aligned.', | |
| fix(fixer) { | |
| const padding = ' '.repeat( Math.max( 0, correctPaddingLength - 3 ) ) + " = "; | |
| return fixer.replaceTextRange( | |
| [idx_endOfMemberName, idx_startOfInitializer], | |
| padding | |
| ); | |
| }, | |
| }); | |
| return; | |
| } | |
| }); | |
| }, | |
| } | |
| }, | |
| }; | |
| /** | |
| * @returns { string | number | undefined } | |
| */ | |
| function getEnumMemberValue( member, context ) | |
| { | |
| if ( !member?.initializer ) | |
| { | |
| return undefined; | |
| } | |
| let value = member.initializer.value; | |
| if ( value !== undefined ) | |
| { | |
| return value; | |
| } | |
| // evaluate expressions (1 << 30) | |
| try | |
| { | |
| const expression = context.getSourceCode().getText( member.initializer ); | |
| value = eval( expression ); | |
| } | |
| catch ( error ) | |
| { | |
| if ( ( "" + error ).includes( "is not defined" ) ) | |
| { | |
| // some complex expression (could be self-referential, or using some constant value) | |
| return EVAL_COMPLEX_EXPRESSION; | |
| } | |
| return error; | |
| } | |
| return value; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment