Last active
December 9, 2025 15:51
-
-
Save dsheiko/aea93ba0b7673d68e098c9d368b4287d to your computer and use it in GitHub Desktop.
Custom ESLint Rules: Enforcing Consistent Inner Spacing (eslint@^9)
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
| // More than a decade ago, I adopted jQuery-style formatting, and I still use it in my JavaScript today. | |
| // I find that common style like fn(1,[1,2,{a:1}]) is less readable than fn( 1, [ 1, 2, { a: 1 } ] ). | |
| // Below are examples of code that will trigger ESLint warnings about missing surrounding spaces. | |
| const array = [1,2,3]; // should report missing space after '[' and before ']' | |
| const obj = {a:1,b:2}; // should report missing space after '{' and before '}' | |
| const arrayNested = [[ 1 ], { a: 1 }]; // however, nested array should report only inner brackets (this statement is correct) | |
| if(foo){ return; } // missing spaces | |
| while(bar){ doSomething();} // missing spaces | |
| for(let i=0;i<10;i++){ } // missing spaces | |
| // --- SPACE IN FUNCTION PARENS --- | |
| function foo(a,b) { // should report missing space after '(' and before ')' | |
| return a + b; | |
| } | |
| const bar = function(a, b) { // should report missing space after '(' if any | |
| return a * b; | |
| }; | |
| const baz = (x,y) => x + y; // should report missing space after '(' and before ')' | |
| new URL(process.env.DATABASE_URL ); // should report missing space after '(' | |
| // Call with object/array literal – should skip space enforcement | |
| foo({ a: 1, b: 2 }, [1,2,3]); | |
| // --- REQUIRE BLOCK IF --- | |
| if (foo) return; // should convert to block | |
| if (bar) doSomething(); // should convert to block | |
| if (baz) { console.log("ok"); } // already has block, no error | |
| // If with else single-line | |
| if (foo) return; else bar(); // should convert both to blocks | |
| // Nested if | |
| if (foo) if (bar) return; // both should be converted to blocks | |
| // JSX expressions to test spacing (for future enhancement) | |
| const element = <div>{foo}</div>; // should report missing spaces inside {} | |
| const element2 = <div>{ foo }</div>; // correct, should not report | |
| const element3 = <Component prop={value}/>; // check JSX self-closing spacing |
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
| import * as babelParser from "@babel/eslint-parser"; | |
| import nextPlugin from "@next/eslint-plugin-next"; | |
| import reactPlugin from "eslint-plugin-react"; | |
| import reactHooksPlugin from "eslint-plugin-react-hooks"; | |
| import spacingLiterals from "./spacing-literals.mjs"; | |
| import requireBlockIf from "./require-block-if.mjs"; | |
| export default [ | |
| { | |
| ignores: [ | |
| "**/*.stories.*", | |
| "**/*.test.*", | |
| "eslint-rules/*.js", | |
| ], | |
| }, | |
| { | |
| plugins: { | |
| "@next/next": nextPlugin, | |
| react: reactPlugin, | |
| "react-hooks": reactHooksPlugin, | |
| custom: { | |
| rules: { | |
| "space-in-function-parens": spacingLiterals, | |
| "require-block-if": requireBlockIf, | |
| }, | |
| }, | |
| }, | |
| rules: { | |
| // enforce consistent spacing inside function parentheses | |
| "custom/space-in-function-parens": "error", | |
| // require block statements for single-line if statements | |
| "custom/require-block-if": "error", | |
| // recommended Next.js rules | |
| ...nextPlugin.configs.recommended.rules, | |
| ...nextPlugin.configs[ "core-web-vitals" ].rules, | |
| // require curly braces for all control statements | |
| "curly": ["error", "all"], | |
| // enforce double quotes in strings, allow template literals | |
| "quotes": ["error", "double", { avoidEscape: true, allowTemplateLiterals: true }], | |
| // enforce comma spacing: no space before, one space after | |
| "comma-spacing": ["error", { "before": false, "after": true }], | |
| // warn on console.log/console.warn but allow console.error/info | |
| "no-console": ["warn", { allow: ["warn", "error", "info"] }], | |
| // enforce spacing before self-closing tag in JSX | |
| "react/jsx-tag-spacing": ["error", { | |
| "beforeSelfClosing": "always" | |
| }], | |
| // allow unescaped entities like '>' or '"' | |
| "react/no-unescaped-entities": "off", | |
| // allow anonymous default exports | |
| "import/no-anonymous-default-export": "off", | |
| // turn off exhaustive-deps warning for React hooks | |
| "react-hooks/exhaustive-deps": "off", | |
| // React hook used in a file that is not a client component | |
| "react-hooks/rules-of-hooks": "error", | |
| // enforce spaces inside JSX curly braces: { foo } instead of {foo} | |
| "react/jsx-curly-spacing": ["error", { | |
| "when": "always", | |
| "children": true, | |
| "spacing": { "objectLiterals": "always" } | |
| }], | |
| } | |
| }, | |
| { | |
| files: ["src/**/*.{js,jsx,ts,tsx}"], | |
| languageOptions: { | |
| parser: babelParser, | |
| parserOptions: { | |
| requireConfigFile: false, | |
| babelOptions: { | |
| presets: ["next/babel"], | |
| }, | |
| }, | |
| } | |
| }, | |
| ]; |
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
| export default { | |
| meta: { | |
| type: "suggestion", | |
| docs: { | |
| description: "Enforce block statements for single-line if statements", | |
| }, | |
| fixable: "code", | |
| schema: [], | |
| messages: { | |
| requireBlock: "Single-line if statement should use a block.", | |
| }, | |
| }, | |
| create( context ) { | |
| const sourceCode = context.getSourceCode(); | |
| return { | |
| IfStatement( node ) { | |
| // Skip if already has a block | |
| if ( node.consequent.type === "BlockStatement" ) { | |
| return; | |
| } | |
| context.report( { | |
| node: node, | |
| messageId: "requireBlock", | |
| fix( fixer ) { | |
| // Get text of the original consequent | |
| const consequentText = sourceCode.getText( node.consequent ); | |
| // Wrap it in braces with proper indentation | |
| const indent = " ".repeat( node.loc.start.column + 2 ); // add 2 spaces inside block | |
| const lineBreak = "\n"; | |
| const fixedText = `{${lineBreak}${indent}${consequentText}${lineBreak}${" ".repeat( node.loc.start.column )}}`; | |
| return fixer.replaceText( node.consequent, fixedText ); | |
| }, | |
| } ); | |
| }, | |
| }; | |
| }, | |
| }; |
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
| export default { | |
| meta: { | |
| type: "layout", | |
| version: "2.1.0", | |
| docs: { | |
| description: | |
| "Enforce space inside parentheses, brackets, braces, and control statements, except for nested arrays/objects or function literals", | |
| }, | |
| fixable: "whitespace", | |
| schema: [], | |
| messages: { | |
| missingSpaceAfter: "Missing space after '('.", | |
| missingSpaceBefore: "Missing space before ')'.", | |
| missingSpaceAfterBracket: "Missing space after '['.", | |
| missingSpaceBeforeBracket: "Missing space before ']'.", | |
| missingSpaceAfterBrace: "Missing space after '{'.", | |
| missingSpaceBeforeBrace: "Missing space before '}'.", | |
| }, | |
| }, | |
| create( context ) { | |
| const sourceCode = context.getSourceCode(); | |
| /** --- Helpers --- */ | |
| function checkParens( opening, first, last ) { | |
| if ( !opening || !first || !last ) { | |
| return; | |
| } | |
| if ( !sourceCode.isSpaceBetweenTokens( opening, first ) ) { | |
| context.report( { | |
| loc: opening.loc.end, | |
| messageId: "missingSpaceAfter", | |
| fix: fixer => fixer.insertTextAfter( opening, " " ), | |
| } ); | |
| } | |
| if ( !sourceCode.isSpaceBetweenTokens( last, last.parentClosingParen ) ) { | |
| context.report( { | |
| loc: last.parentClosingParen.loc.start, | |
| messageId: "missingSpaceBefore", | |
| fix: fixer => fixer.insertTextBefore( last.parentClosingParen, " " ), | |
| } ); | |
| } | |
| } | |
| function checkBrackets( opening, innerToken, closing ) { | |
| if ( !sourceCode.isSpaceBetweenTokens( opening, innerToken ) ) { | |
| context.report( { | |
| loc: opening.loc.end, | |
| messageId: "missingSpaceAfterBracket", | |
| fix: fixer => fixer.insertTextAfter( opening, " " ), | |
| } ); | |
| } | |
| if ( !sourceCode.isSpaceBetweenTokens( innerToken, closing ) ) { | |
| context.report( { | |
| loc: closing.loc.start, | |
| messageId: "missingSpaceBeforeBracket", | |
| fix: fixer => fixer.insertTextBefore( closing, " " ), | |
| } ); | |
| } | |
| } | |
| function checkBraces( opening, innerToken, closing ) { | |
| if ( !sourceCode.isSpaceBetweenTokens( opening, innerToken ) ) { | |
| context.report( { | |
| loc: opening.loc.end, | |
| messageId: "missingSpaceAfterBrace", | |
| fix: fixer => fixer.insertTextAfter( opening, " " ), | |
| } ); | |
| } | |
| if ( !sourceCode.isSpaceBetweenTokens( innerToken, closing ) ) { | |
| context.report( { | |
| loc: closing.loc.start, | |
| messageId: "missingSpaceBeforeBrace", | |
| fix: fixer => fixer.insertTextBefore( closing, " " ), | |
| } ); | |
| } | |
| } | |
| /** --- Reporters --- */ | |
| function reportCallExpression( node ) { | |
| if ( !node.arguments.length ) { | |
| return; | |
| } | |
| const opening = sourceCode.getTokenBefore( node.arguments[ 0 ] ); | |
| const closing = sourceCode.getLastToken( node ); | |
| const tokensInside = sourceCode.getTokensBetween( opening, closing, { includeComments: false } ); | |
| if ( !tokensInside.length ) { | |
| return; | |
| } | |
| const first = tokensInside[ 0 ]; | |
| const last = tokensInside[ tokensInside.length - 1 ]; | |
| last.parentClosingParen = closing; | |
| checkParens( opening, first, last ); | |
| } | |
| function reportFunctionParams( fnNode ) { | |
| if ( !fnNode.params || !fnNode.params.length ) { | |
| return; | |
| } | |
| const firstParam = fnNode.params[ 0 ]; | |
| const lastParam = fnNode.params[ fnNode.params.length - 1 ]; | |
| const opening = sourceCode.getTokenBefore( firstParam ); | |
| const closing = sourceCode.getTokenAfter( lastParam ); | |
| const tokensInside = sourceCode.getTokensBetween( opening, closing, { includeComments: false } ); | |
| if ( !tokensInside.length ) { | |
| return; | |
| } | |
| const first = tokensInside[ 0 ]; | |
| const last = tokensInside[ tokensInside.length - 1 ]; | |
| last.parentClosingParen = closing; | |
| // Skip if first param is array/object/function literal | |
| if ( | |
| ![ "ArrayExpression", "ObjectExpression", "ArrowFunctionExpression", "FunctionExpression"].includes( firstParam.type ) | |
| ) { | |
| checkParens( opening, first, last ); | |
| } | |
| } | |
| function reportArrayExpression( node ) { | |
| if ( !node.elements.length ) { | |
| return; | |
| } | |
| const opening = sourceCode.getFirstToken( node ); | |
| const closing = sourceCode.getLastToken( node ); | |
| const hasNonLiteral = node.elements.some( | |
| el => el && ![ "ArrayExpression", "ObjectExpression"].includes( el.type ) | |
| ); | |
| if ( hasNonLiteral ) { | |
| const firstToken = sourceCode.getFirstToken( node.elements[ 0 ] ); | |
| const lastToken = sourceCode.getLastToken( node.elements[ node.elements.length - 1 ] ); | |
| checkBrackets( opening, firstToken, closing ); | |
| } | |
| } | |
| function reportObjectExpression( node ) { | |
| if ( !node.properties.length ) { | |
| return; | |
| } | |
| const opening = sourceCode.getFirstToken( node ); | |
| const closing = sourceCode.getLastToken( node ); | |
| const hasNonLiteral = node.properties.some( | |
| prop => | |
| prop.value && | |
| ![ "ArrayExpression", "ObjectExpression"].includes( prop.value.type ) | |
| ); | |
| if ( hasNonLiteral ) { | |
| const firstToken = sourceCode.getFirstToken( node.properties[ 0 ] ); | |
| const lastToken = sourceCode.getLastToken( node.properties[ node.properties.length - 1 ] ); | |
| checkBraces( opening, firstToken, closing ); | |
| } | |
| } | |
| /** --- Control statements --- */ | |
| function reportControlStatement( testNode ) { | |
| const opening = sourceCode.getTokenBefore( testNode ); | |
| const closing = sourceCode.getTokenAfter( testNode ); | |
| const tokensInside = sourceCode.getTokensBetween( opening, closing, { includeComments: false } ); | |
| if ( !tokensInside.length ) { | |
| return; | |
| } | |
| const first = tokensInside[ 0 ]; | |
| const last = tokensInside[ tokensInside.length - 1 ]; | |
| last.parentClosingParen = closing; | |
| checkParens( opening, first, last ); | |
| } | |
| return { | |
| // Functions | |
| FunctionDeclaration: reportFunctionParams, | |
| FunctionExpression: reportFunctionParams, | |
| ArrowFunctionExpression: reportFunctionParams, | |
| MethodDefinition( node ) { | |
| reportFunctionParams( node.value ); | |
| }, | |
| Property( node ) { | |
| if ( node.method ) { | |
| reportFunctionParams( node.value ); | |
| } | |
| }, | |
| // Calls | |
| CallExpression: reportCallExpression, | |
| NewExpression: reportCallExpression, | |
| // Arrays / Objects | |
| ArrayExpression: reportArrayExpression, | |
| ObjectExpression: reportObjectExpression, | |
| // Member expressions | |
| MemberExpression( node ) { | |
| if ( !node.computed ) { | |
| return; | |
| } | |
| const property = node.property; | |
| const opening = sourceCode.getTokenBefore( property ); | |
| const closing = sourceCode.getTokenAfter( property ); | |
| if ( !opening || opening.value !== "[" ) { | |
| return; | |
| } | |
| if ( !closing || closing.value !== "]" ) { | |
| return; | |
| } | |
| checkBrackets( opening, property, closing ); | |
| }, | |
| // Control statements | |
| IfStatement( node ) { | |
| reportControlStatement( node.test ); | |
| }, | |
| WhileStatement( node ) { | |
| reportControlStatement( node.test ); | |
| }, | |
| ForStatement( node ) { | |
| // Use init and update tokens to get full parens range | |
| const opening = sourceCode.getTokenBefore( node.init ?? node ); | |
| const closing = sourceCode.getTokenAfter( node.update ?? node ); | |
| const tokensInside = sourceCode.getTokensBetween( opening, closing, { includeComments: false } ); | |
| if ( !tokensInside.length ) { | |
| return; | |
| } | |
| const first = tokensInside[ 0 ]; | |
| const last = tokensInside[ tokensInside.length - 1 ]; | |
| last.parentClosingParen = closing; | |
| checkParens( opening, first, last ); | |
| }, | |
| SwitchStatement( node ) { | |
| reportControlStatement( node.discriminant ); | |
| }, | |
| CatchClause( node ) { | |
| if ( !node.param ) { | |
| return; | |
| } | |
| reportControlStatement( node.param ); | |
| }, | |
| }; | |
| }, | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment