Skip to content

Instantly share code, notes, and snippets.

@DarkRoku12
Last active September 8, 2023 05:29
Show Gist options
  • Save DarkRoku12/247defa56ac6475043a42145d51ab013 to your computer and use it in GitHub Desktop.
Save DarkRoku12/247defa56ac6475043a42145d51ab013 to your computer and use it in GitHub Desktop.
TypeScript compatible indent rule for ESLint.
// Taken inspiration and adapted from:
// https://github.com/funmaker/eslint-plugin-indent-empty-lines/blob/4f397f490dcf0329feccef6df23b32e366fc2faf/lib/rules/indent-empty-lines.js
// https://github.com/eslint/eslint/blob/60f6a06f521c514e3834dd9f82821b10c69a5f00/lib/rules/no-trailing-spaces.js
// https://astexplorer.net -> Configure: @typescript-eslint.
// Created by DarkRoku12.
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/*
The logic behind this code is to indent the first 'token' in the line if the indentation is not correct.
To achieve that, we need to remove the previous indentation and then set our desired one.
Note:
- We should not worry about previous line, since code can only go forward.
- Block commentaries can start at the end of the line, in the middle of them, and in the beggining.
- Block commentaries may hold more than one line.
- There may be code after a block commentary (in the same line).
- Template literals (tokens) are parsed in a similar ways like comments.
*/
/*
(TODO) LogicalExpression: || , && , ??
(TODO) BinaryExpression: & , |
(TODO) TSIntersectionType: type a = number & string ;
(TODO) TSUnionType: type a = number | string ;
(handled) TSTypeAliasDeclaration: type name = some;
(handled) TSConditionalType: type: ? ---> A : any[] extends any[] ? true : false ;
(handled) VariableDeclarator: = ---> const x = "something" ;
(handled) Conditional-Expression: ? ---> x ? a : b ;
(handled) IfStatement: if(){ ... } , if() [single line] | if()..else if()..else ;
(handled) ForStatement | ForOfStatement | ForInStatement | WhileStatement | DoWhileStatement
(handled) SwitchStatement
(Handled) To many others but i'm lazy to list it here.
*/
/* eslint-disable no-unused-vars */
// This flags (config) should be a proper rule config.
const FLAGS =
{
allow_extra_indent_on_comments : true , // Only on 'Block' type comments.
fix_with_extra_indent_on_comments : true , // Indent twice inside 'Block' type comments.
jsx_indent_initial_line : true , // Must indent initial TSX element line?
jsx_indent_final_line : false , // Must indent final TSX element line?
};
const AreInSameLine = function( locA , locB )
{
return locA.start.line == locB.start.line;
};
const AreInSameLineComp = function( locA , whereA , locB , whereB )
{
return locA[whereA].line == locB[whereB].line;
};
const GetLinesFromLoc = function( linesMap , locA , locB )
{
locB = locB || locA;
const lines = [];
for( let i = locA.start.line; i <= locB.end.line; ++i )
{
const line = linesMap[ i ];
if( line ) lines.push( line );
}
return lines;
};
// Adds <amount> indents levels to an array of lines.
const AddIndentLevel = function( lines , amount , extra )
{
for( const line of lines )
{
line.level = line.level + amount;
if( extra ) Object.assign( line , extra );
}
};
// Adds <amount> of balance to a given location (used to adjust indentation of comentaries).
const AdjustBalance = function( linesMap , loc , where , amount )
{
const line = where == "start" ? linesMap[ loc.start.line ] : linesMap[ loc.end.line ];
if( line ) line.balance += amount;
};
// Adds <amount> of balance to a give set of lines.
const AdjustLinesBalance = function( lines , amount )
{
for( const line of lines ) line.balance = line.balance + amount;
};
const AdjustBalanceIfNotFlag = function( linesMap , loc , where , amount , flag_property )
{
const line = where == "start" ? linesMap[ loc.start.line ] : linesMap[ loc.end.line ];
if( line && !line[ flag_property ] )
{
line[ flag_property ] = true;
line.balance += amount;
}
};
/*
This compares if locA and locB are in the same line, if not, this function
will basically add one indentation level to all lines addressed by locB.
This effectively simulates the `balance` level on the "Program" trigger.
Since comments relay on the `balance` level, we should add the respective levels
to the locA starting line.
*/
const AddIndentIfNeeded = function( linesMap , locA , locB , amount , extra = null )
{
if( !AreInSameLine( locA , locB ) )
{
AdjustBalance( linesMap , locA , "start" , amount );
AddIndentLevel( GetLinesFromLoc( linesMap , locB ) , amount , extra );
return true;
}
return false;
};
// This version does not adjust the balance.
const AddRawIndentIfNeeded = function( linesMap , locA , locB , amount , extra = null )
{
if( !AreInSameLine( locA , locB ) )
{
AddIndentLevel( GetLinesFromLoc( linesMap , locB ) , amount , extra );
return true;
}
return false;
};
const AddIndentIfNeededFiltered = function( linesMap , locA , locB , amount , extra , filter )
{
if( !AreInSameLine( locA , locB ) )
{
const lines = GetLinesFromLoc( linesMap , locB );
AdjustBalance( linesMap , locA , "start" , amount );
AddIndentLevel( lines.filter( filter ) , amount , extra );
return true;
}
return false;
};
const ShareIndentLevelFiltered = function( linesMap , fromLocA , toLocB , filter )
{
const fromLine = linesMap[ fromLocA.start.line ];
const toLines = GetLinesFromLoc( linesMap , toLocB ).filter( filter );
for( const line of toLines )
{
line.level = fromLine.level;
}
};
const ShareIndentLevel = function( linesMap , fromLocA , toLocB )
{
ShareIndentLevelFiltered( linesMap , fromLocA , toLocB , () => true );
};
// Try to find the previous line given a line index (1-based).
const GetPreviousLine = function( linesMap , lineIdx )
{
// Lines starts at 1.
if( lineIdx < 2 ) return null;
let line = null;
do
line = linesMap[ --lineIdx ];
while( !line && lineIdx > 1 );
return line;
};
// Try to find the next line given a line index (1-based).
const GetNextLine = function( linesMap , lineIdx , stopAt )
{
for( let i = lineIdx + 1; i <= stopAt; ++i )
{
const line = linesMap[ i ];
if( line ) return line;
}
return null;
};
// Creates a new location object.
const NewLoc = function( line_start , col_start , line_end , col_end )
{
return {
start : { line : line_start , column : col_start } ,
end : { line : line_end , column : col_end }
};
};
const LOOPS_SELECTOR = [
"ForStatement" ,
"ForOfStatement" ,
"ForInStatement" ,
"WhileStatement" ,
"DoWhileStatement"
].join( "," );
const TS_TEMPLATE_PARAMS =
[
"TSTypeParameterInstantiation" ,
"TSTypeParameterDeclaration"
].join( "," );
// Export the rule.
module.exports = {
meta : {
docs : {
description : "Enforce indention by Arpictech rules" ,
category : "Stylistic Issues" ,
recommended : true ,
url : "https://eslint.org/docs/rules/indent" ,
} ,
fixable : "whitespace" ,
schema : [] ,
} ,
create( context )
{
const sourceCode = context.getSourceCode();
const linesMap = {};
const memExpMap = {};
const openings = [ "{" , "(" , "[" ];
const endings = [ "}" , ")" , "]" ];
const indentCount = 2;
const indentChar = " ";
return {
// Will begin the execution chain (the first to execute).
Program : function( node )
{
const firstToken = sourceCode.getFirstToken( node );
if( !firstToken ) return;
// This is the current indentation level.
let iLevel = 0;
// Following tokens can be on the same line or in any of the followings.
let currLine = firstToken.loc.start.line;
/*
This represent the 'line balance'.
```
const obj = { a : 1 , b : 2 }
```
Is balanced because both '{' and '}' are on the same line.
So it wont affect the indentation level of the following lines.
*/
let balance = 0;
// Get the list of tokens in the whole program (file).
const tokens = sourceCode.getTokens( node );
// Iterate all tokens first.
for( const token of tokens )
{
const lineIdx = token.loc.start.line;
// Adjust the indentation according to the 'line balance'.
if( lineIdx != currLine )
{
iLevel += balance;
if( iLevel < 0 ) iLevel = 0;
balance = 0; // Reset it.
currLine = lineIdx; // Set as the next one.
}
// Adjust the balance.
if( token.type == "Punctuator" )
{
if( openings.includes( token.value ) )
++balance;
else if( endings.includes( token.value ) )
--balance;
}
// Add or replace the entry in the line's map.
const line = linesMap[ lineIdx ] || {
level : iLevel ,
first : token ,
number : lineIdx ,
balance : balance ,
};
// Replace with new values.
line.level = iLevel;
line.balance = balance;
linesMap[ lineIdx ] = line;
}
} ,
// Will execute at last.
"Program:exit" : function( node )
{
const fixedComments = {}; // Map for already fixed comments.
for( const comment of node.comments )
{
const commentId = `${comment.range[0]}-${comment.range[1]}`;
const fromLineIdx = comment.loc.start.line;
const toLineIdx = comment.loc.end.line;
for( let lineIdx = fromLineIdx; lineIdx <= toLineIdx; ++lineIdx )
{
const line = linesMap[ lineIdx ];
if( !line ) // The line is not present, we must add it.
{
const prevLine = GetPreviousLine( linesMap , lineIdx );
const prevToken = sourceCode.getTokenBefore( comment , { includeComments : true });
/*
This will add an extra level of indentation to the comment,
useful for same-line braces and opening values like `{` and `[`.
We'll disable this for block-like comments to avoid weird indentations.
*/
// Perhaps no-needed rule?
// const extraLevel =
// comment.type != "Block" &&
// prevToken &&
// prevToken.type == "Punctuator" &&
// openings.includes( prevToken.value ) &&
// ( !prevLine || prevLine.first.range[0] != prevToken.range[0] )
// ? 1 : 0;
const extraLevel = 0;
if( prevLine ) // Inherits indentation from previous line.
{
linesMap[ lineIdx ] = {
level : prevLine.level + prevLine.balance + extraLevel ,
first : comment ,
number : lineIdx ,
balance : 0 ,
comment : commentId ,
inherith : true ,
custom_loc : NewLoc( lineIdx , 0 , lineIdx , 1 ) ,
};
}
else // Add a default line with 0 indentation.
{
linesMap[ lineIdx ] = {
level : 0 ,
first : comment ,
number : lineIdx ,
balance : 0 ,
comment : commentId ,
inherith : false ,
custom_loc : NewLoc( lineIdx , 0 , lineIdx , 1 ) ,
};
}
}
else if( line.first.range[0] > comment.range[0] ) // We need to replace which is the `first` node.
{
line.first = comment;
line.comment = commentId;
line.inherith = false;
}
}
}
// Make adjustment and show the report(s).
for( const lineIdx in linesMap )
{
const line = linesMap[ lineIdx ];
const rawLine = sourceCode.lines[ lineIdx - 1 ];
if( line.skip ) continue;
// Eval if we should decrease the indent level of this line.
if( line.first.type == "Punctuator" )
{
if( endings.includes( line.first.value ) )
{
--line.level;
}
}
// Cap to zero if needed.
if( line.level < 0 ) line.level = 0;
// Must match this indentation.
const bestIndent = indentChar.repeat( indentCount * line.level );
// Current indentation.
line.indent_curr = rawLine.match( /^\s*/g )[0].length;
// Required indentation.
line.indent_req = bestIndent.length;
// Current indentation matches requirements.
if( line.indent_curr == line.indent_req ) continue;
// Allow extra indent on commentaries as needed.
const should_skip =
FLAGS.allow_extra_indent_on_comments &&
line.comment &&
line.first.type == "Block" &&
line.indent_curr - 2 == line.indent_req;
if( should_skip ) continue;
// Default fix function.
let fix = function( fixer )
{
const fixRange =
[
line.first.range[0] - line.indent_curr ,
line.first.range[0] ,
];
return fixer.replaceTextRange( fixRange , bestIndent );
};
// Handle commentaries 'first' lines.
if( line.comment )
{
// Set fix for comments.
fix = function( fixer )
{
const fixes = [];
// Skip for already fixed comments.
if( fixedComments[ line.comment ] ) return fixes;
fixedComments[ line.comment ] = true;
// Fix the comment (handles multi-line).
const inside_padding = FLAGS.fix_with_extra_indent_on_comments ? bestIndent + indentChar.repeat( indentCount ) : bestIndent;
let newComment = line.first.value.replaceAll( /\r?\n\s*/g , `\n${inside_padding}` );
// Get the line in which the comment starts.
const startLine = linesMap[ line.first.loc.start.line ];
/*
If the commentary 'owns' the starting lines and
that line have initial paddings we must remove those blanks.
*/
if( startLine.comment == line.comment && startLine.indent_curr > 0 )
{
const clearRange = [ line.first.range[0] - startLine.indent_curr , line.first.range[0] ];
fixes.push( fixer.replaceTextRange( clearRange , "" ) );
}
// If the commentary 'owns' the line we can safely add indentation at the beginning.
const begIndent = startLine.comment == line.comment ? bestIndent : "";
if( line.first.type == "Block" ) // For blocks: /* <comment>
fixes.push( fixer.replaceText( line.first , `${begIndent}/*${newComment}*/` ) );
else // For lines: // <comment>
fixes.push( fixer.replaceText( line.first , `${begIndent}//${newComment}` ) );
return fixes;
};
}
// Report the error with the fix.
context.report(
{
node ,
loc : line.custom_loc || line.first.loc ,
message : `Wrong indentation: expected ${line.indent_req}, got: ${line.indent_curr}.` ,
fix : fix ,
});
// We don't need to hold that reference any more.
line.first = undefined;
}
} ,
// Skip template literals (both JS/TS)
"TemplateLiteral , TSLiteralType , TSTemplateLiteralType" : function( node )
{
/*
We only need to skip after the first line of a literal.
If the literal does not goes more than 1 line it wont bother us
*/
for( let i = node.loc.start.line + 1; i <= node.loc.end.line; ++i )
{
const line = linesMap[i];
if( line ) line.skip = "literal"; // Skip reason.
}
} ,
// Skip JSX elements.
"JSXElement" : function( node )
{
const startOffset = FLAGS.jsx_indent_initial_line ? 1 : 0;
const endOffset = FLAGS.jsx_indent_final_line ? -1 : 0;
const skipStart = node.loc.start.line + startOffset;
const skipEnd = node.loc.end.line + endOffset;
for( let i = skipStart; i <= skipEnd; ++i )
{
const line = linesMap[i];
if( line ) line.skip = "jsx-element"; // Skip reason.
}
} ,
// Re-indent variable declarations.
VariableDeclarator : function( node )
{
const id = node.id.loc;
const init = node.init?.loc;
if( !init ) return; // Nothing to do here.
const prev = sourceCode.getTokenBefore( node.init );
const ftoken = sourceCode.getFirstToken( node.init );
const black = [ "{" , "[" ];
// Skip, this was already handled.
if( prev.type == "Punctuator" && prev.value == "(" ) return;
// Skip, does not need more indentation.
if( ftoken && ftoken.type == "Punctuator" && black.includes( ftoken.value ) ) return;
// If those aren't in the same line -> indent one level more.
if( AddIndentIfNeeded( linesMap , id , init , 1 ) )
{
AdjustBalance( linesMap , init , "end" , -1 );
}
} ,
// Re-indent conditional expressions.
ConditionalExpression : function( node )
{
const test = node.test.loc;
const first = node.consequent.loc;
const second = node.alternate.loc;
// If those aren't in the same line -> indent one level more.
let restore = false;
restore |= AddIndentIfNeeded( linesMap , test , first , 1 );
restore |= AddIndentIfNeeded( linesMap , first , second , 1 );
if( restore ) AdjustBalance( linesMap , second , "end" , -1 );
} ,
// Re-indent if/else if/else statements.
IfStatement : function( node )
{
const blacklist = [ "BlockStatement" , "IfStatement" ];
// Handle: if & else-if.
if( !blacklist.includes( node.consequent.type ) )
{
const cond = node.test.loc;
const stmt = node.consequent.loc;
// If those aren't in the same line -> indent one level more.
if( AddIndentIfNeeded( linesMap , cond , stmt , 1 ) )
{
AdjustBalance( linesMap , stmt , "end" , -1 );
}
}
// Handle: else.
if( node.alternate && !blacklist.includes( node.alternate.type ) )
{
const _else = sourceCode.getTokenBefore( node.alternate );
// Skip if not an 'else'.
if( _else.type != "Keyword" || !_else.value == "else" ) return;
const cond = _else.loc;
const stmt = node.alternate.loc;
// If those aren't in the same line -> indent one level more.
if( AddIndentIfNeeded( linesMap , cond , stmt , 1 ) )
{
AdjustBalance( linesMap , stmt , "end" , -1 );
}
}
} ,
// Re-indent TSTypeAliasDeclaration.
TSTypeAliasDeclaration : function( node )
{
const id = node.id.loc;
const params = node.typeParameters?.loc;
const equal = node.typeAnnotation.loc;
const openBrace = sourceCode.getFirstToken( node.typeAnnotation );
const blacklist = [ "{" , "[" ];
let skipEqual = openBrace && openBrace.type == "Punctuator" && blacklist.includes( openBrace.value );
if( !skipEqual )
{
// If those aren't in the same line -> indent one level more.
if( AddIndentIfNeeded( linesMap , id , equal , 1 ) )
{
AdjustBalance( linesMap , equal , "end" , -1 );
}
}
// if( !params ) // No type parameters?
// {
// if( !skipEqual )
// {
// // If those aren't in the same line -> indent one level more.
// if( AddIndentIfNeeded( linesMap , id , equal , 1 ) )
// {
// AdjustBalance( linesMap , equal , "end" , -1 );
// }
// }
// }
// else // With type parameters.
// {
// // This is needed in order to skip overlapping lines and avoid extra indentation.
// const extra = { "id-params" : true };
// const filter = line => !line[ "id-params" ];
// // Add the proper indentation as needed.
// let restore = AddIndentIfNeeded( linesMap , id , params , 1 , extra );
// skipEqual = skipEqual && AreInSameLine( id , params );
// if( !skipEqual ) // Skip if id and params starts on the same line.
// {
// restore |= AddIndentIfNeededFiltered( linesMap , params , equal , 1 , null , filter );
// }
// if( restore ) AdjustBalance( linesMap , equal , "end" , -1 );
// }
} ,
// Re-indent TypeScript template parameters.
[ TS_TEMPLATE_PARAMS ] : function( node )
{
let from = node.loc;
for( const param of node.params )
{
AddIndentIfNeeded( linesMap , from , param.loc , 1 );
from = param.loc;
}
} ,
// Re-indent TSConditionalType.
TSConditionalType : function( node )
{
const check = node.checkType.loc;
const exts = node.extendsType.loc;
const first = node.trueType.loc;
const second = node.falseType.loc;
// If those aren't in the same line -> indent one level more.
let restore = false;
restore |= AddIndentIfNeeded( linesMap , check , exts , 1 );
restore |= AddIndentIfNeeded( linesMap , exts , first , 1 );
restore |= AddIndentIfNeeded( linesMap , first , second , 1 );
// Restore the balance if needed.
if( restore ) AdjustBalance( linesMap , second , "end" , -1 );
} ,
// Re-indent new & call-expresion.
"NewExpression, CallExpression" : function( node )
{
const len = node.arguments.length;
if( !len ) return; // Nothing to do.
const lastArg = node.arguments[ len - 1 ];
const lastArgToken = sourceCode.getLastToken( lastArg );
const lastCallToken = sourceCode.getLastToken( node );
// const isClosingChar = lastArgToken.type == "Punctuator" && endings.includes( lastArgToken.value );
const isCloseParen = lastCallToken.type == "Punctuator" && lastCallToken.value == ")";
const inSameLine = AreInSameLine( lastArgToken.loc , lastCallToken.loc );
if( inSameLine && isCloseParen )
{
const funcId = `fn-args-${node.range[0]}-${node.range[1]}`;
const extra = { [funcId] : true };
const callee = node.callee.loc;
const filter = line => !line[ funcId ] && line.number != callee.end.line && line.number != callee.start.line;
for( const arg of node.arguments )
{
const lines = GetLinesFromLoc( linesMap , arg.loc );
const filtered = lines.filter( filter );
AddIndentLevel( filtered , -1 , extra );
if( filtered.length )
{
const first_line = lines[0];
const last_line = filtered[ filtered.length - 1 ];
AdjustLinesBalance( [ first_line ] , -1 );
AdjustLinesBalance( [ last_line ] , 1 );
}
}
}
} ,
// Re-indent function declarations.
FunctionDeclaration : function( node )
{
// NYI | I'm not needing it right now.
if( !node.typeParameters ) return; // Nothing to do here.
} ,
// Re-indent member expressions.
"MemberExpression:exit" : function( node )
{
/////////////// TODO: Not used now? ///////////////
// const obj = node.object.loc;
// const prop = node.property.loc;
// /*
// This method are called from last member to first member.
// So in x.y.z goes -> z -> y -> x.
// We use :exit to invert that order.
// */
// const currLine = linesMap[ prop.start.line ];
// const nextLine = GetNextLine( linesMap , prop.start.line , sourceCode.lines.length );
// const reset = ( currLine && currLine[ "obj-prop" ] ) && ( !nextLine || !nextLine[ "obj-prop" ] );
// // console.log( "MemberExpression:exit" , node.property.name , obj.start.line );
// if( reset ) // Should reset the balance.
// {
// // AdjustBalanceIfNotFlag( linesMap , prop , "end" , -1 , `mem-exp-line-${obj.start.line}` );
// // // AdjustBalance( linesMap , prop , "end" , -1 );
// }
} ,
// Re-indent member expressions.
MemberExpression : function( node )
{
const obj = node.object.loc;
const prop = node.property.loc;
const extra = { "obj-prop" : true };
const filter = line => !line[ "obj-prop" ] && line.number != node.parent.loc.start.line;
// If those aren't in the same line -> indent one level more.
if( !AreInSameLineComp( obj , "end" , prop , "start" ) )
{
const parentStartLine = node.parent.loc.start.line;
const lines = GetLinesFromLoc( linesMap , node.parent.loc );
AddIndentLevel( lines.filter( filter ) , 1 , extra );
// Only do it once per member expression (starting line location).
if( !memExpMap[ parentStartLine ] )
{
memExpMap[ parentStartLine ] = node.parent.loc;
AdjustBalance( linesMap , node.parent.loc , "end" , -1 );
}
// Indent if the first token is an opening.
const ftoken = sourceCode.getFirstToken( node );
const ftLine = linesMap[ ftoken.loc.start.line ];
const isOpen = ftoken.type == "Punctuator" && openings.includes( ftoken.value );
const ownLine = ftLine && ftLine.first.range[0] == ftoken.range[0];
if( ftLine && !ftLine[ "mem-exp" ] && isOpen && ownLine )
{
// Adjust for child nodes/expressions.
AddIndentLevel( [ ftLine ] , 1 , { "mem-exp" : true });
// Adjust for comments.
const prevLine = GetPreviousLine( linesMap , ftLine.number );
if( prevLine ) prevLine.balance += 1;
}
}
} ,
SwitchCase : function( node )
{
const test = ( node.test || node.parent ).loc;
const blacklist = [ "BlockStatement" ];
for( const stmt of node.consequent )
{
if( !blacklist.includes( stmt.type ) )
{
// If those aren't in the same line -> indent one level more.
AddRawIndentIfNeeded( linesMap , test , stmt.loc , 1 );
}
}
// We always adjust +1 for switch-case.
AdjustBalance( linesMap , test , "end" , 1 );
} ,
// Re-indent loops.
[ LOOPS_SELECTOR ] : function( node )
{
const black = [ "BlockStatement" ];
if( !black.includes( node.body.type ) )
{
// Must eval in this exact order.
const comp = node.type == "DoWhileStatement"
? node
: node.update || node.right || node.left || node.test;
const _node = comp.loc;
const stmt = node.body.loc;
// If those aren't in the same line -> indent one level more.
if( AddIndentIfNeeded( linesMap , _node , stmt , 1 ) )
{
AdjustBalance( linesMap , stmt , "end" , -1 );
}
}
} ,
};
} ,
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment