Skip to content

Instantly share code, notes, and snippets.

@dsheiko
Last active December 9, 2025 15:51
Show Gist options
  • Select an option

  • Save dsheiko/aea93ba0b7673d68e098c9d368b4287d to your computer and use it in GitHub Desktop.

Select an option

Save dsheiko/aea93ba0b7673d68e098c9d368b4287d to your computer and use it in GitHub Desktop.
Custom ESLint Rules: Enforcing Consistent Inner Spacing (eslint@^9)
// 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
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"],
},
},
}
},
];
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 );
},
} );
},
};
},
};
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