Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

padded-blocks: Add "start" & "end" options #308

Open
3 of 4 tasks
erosman opened this issue Apr 1, 2024 · 3 comments
Open
3 of 4 tasks

padded-blocks: Add "start" & "end" options #308

erosman opened this issue Apr 1, 2024 · 3 comments
Labels
accepted The propsal get accepted, PR welcome! enhancement New feature or request

Comments

@erosman
Copy link

erosman commented Apr 1, 2024

Clear and concise description of the problem

Add "start" & "end" options

It would be useful to have more control over the "padded-blocks".
(I write classes/switches with padded start only.)

/* eslint @stylistic/js/padded-blocks: ["error", { "blocks": "never", "classes": "start" }] */

if (a) {
  b();
}

class C {

  static {
    a();
  }
}

Suggested solution

Add "start" & "end" options to have more control over padding blocks

  • "start": always at start, never at end
  • "end": never at start, always at end

The rule already differentiates between the start & end padding i.e.

const blockHasTopPadding = isPaddingBetweenTokens(tokenBeforeFirst, firstBlockToken)
const blockHasBottomPadding = isPaddingBetweenTokens(lastBlockToken, tokenAfterLast)

Alternative

No response

Additional context

No response

Validations

Contributes

  • If this feature request is accepted, I am willing to submit a PR to fix this issue
@erosman erosman added the enhancement New feature or request label Apr 1, 2024
@erosman
Copy link
Author

erosman commented Apr 1, 2024

Just an initial sketch of an idea .... feel free to edit
(I don't use TypeScript & cant test it.)

Updated code...

/**
 * @fileoverview A rule to ensure blank lines within blocks.
 * @author Mathias Schreck <https://github.com/lo1tuma>
 */

import type { ASTNode, Token, Tree } from '@shared/types'
import { isTokenOnSameLine } from '../../utils/ast-utils'
import { createRule } from '../../utils/createRule'
import type { MessageIds, RuleOptions } from './types'

export default createRule<MessageIds, RuleOptions>({
  meta: {
    type: 'layout',
    docs: {
      description: 'Require or disallow padding within blocks',
      url: 'https://eslint.style/rules/js/padded-blocks',
    },
    fixable: 'whitespace',
    schema: [
      {
        oneOf: [
          {
            type: 'string',
            enum: ['always', 'never', 'start', 'end'],
          },
          {
            type: 'object',
            properties: {
              blocks: {
                type: 'string',
                enum: ['always', 'never', 'start', 'end'],
              },
              switches: {
                type: 'string',
                enum: ['always', 'never', 'start', 'end'],
              },
              classes: {
                type: 'string',
                enum: ['always', 'never', 'start', 'end'],
              },
            },
            additionalProperties: false,
            minProperties: 1,
          },
        ],
      },
      {
        type: 'object',
        properties: {
          allowSingleLineBlocks: {
            type: 'boolean',
          },
        },
        additionalProperties: false,
      },
    ],

    messages: {
      // alwaysPadBlock: 'Block must be padded by blank lines.',
      // neverPadBlock: 'Block must not be padded by blank lines.',
      startPadBlock: 'Block must be padded by a starting blank line.',
      endPadBlock: 'Block must be padded by an ending blank line.',
      startNoPadBlock: 'Block must not be padded by a starting blank line.',
      endNoPadBlock: 'Block must not be padded by an ending blank line.',
    },
  },
  create(context) {
    const options: Record<string, string | boolean> = {}
    // context possibilities: {'string'} | {'string', {...}} | {{...}}
    const defaultOption = typeof context.options[0] === 'string' ? context.options[0] : 'always'
    const exceptOptions = context.options[1] ? context.options[1] : typeof context.options[0] === 'object' ? context.options[0] : {}

    // note: exceptOptions values for blocks|switches|classes must be string (not boolean)
    options.blocks = exceptOptions.blocks || defaultOption
    options.switches = exceptOptions.switches || defaultOption
    options.classes = exceptOptions.classes || defaultOption
    options.allowSingleLineBlocks = exceptOptions.allowSingleLineBlocks === true

    const sourceCode = context.sourceCode

    /**
     * Gets the open brace token from a given node.
     * @param node A BlockStatement or SwitchStatement node from which to get the open brace.
     * @returns The token of the open brace.
     */
    function getOpenBrace(node: Tree.BlockStatement | Tree.StaticBlock | Tree.SwitchStatement | Tree.ClassBody): Token {
      if (node.type === 'SwitchStatement')
        return sourceCode.getTokenBefore(node.cases[0])!

      if (node.type === 'StaticBlock')
        return sourceCode.getFirstToken(node, { skip: 1 })! // skip the `static` token

      // `BlockStatement` or `ClassBody`
      return sourceCode.getFirstToken(node)!
    }

    /**
     * Checks if the given parameter is a comment node
     * @param node An AST node or token
     * @returns True if node is a comment
     */
    function isComment(node: ASTNode | Token) {
      return node.type === 'Line' || node.type === 'Block'
    }

    /**
     * Checks if there is padding between two tokens
     * @param first The first token
     * @param second The second token
     * @returns True if there is at least a line between the tokens
     */
    function isPaddingBetweenTokens(first: Token, second: Token) {
      return second.loc.start.line - first.loc.end.line >= 2
    }

    /**
     * Checks if the given token has a blank line after it.
     * @param token The token to check.
     * @returns Whether or not the token is followed by a blank line.
     */
    function getFirstBlockToken(token: Token) {
      let prev
      let first = token

      do {
        prev = first
        first = sourceCode.getTokenAfter(first, { includeComments: true })!
      } while (isComment(first) && first.loc.start.line === prev.loc.end.line)

      return first
    }

    /**
     * Checks if the given token is preceded by a blank line.
     * @param token The token to check
     * @returns Whether or not the token is preceded by a blank line
     */
    function getLastBlockToken(token: Token) {
      let last = token
      let next

      do {
        next = last
        last = sourceCode.getTokenBefore(last, { includeComments: true })!
      } while (isComment(last) && last.loc.end.line === next.loc.start.line)

      return last
    }

    /**
     * Checks if a node should be padded, according to the rule config.
     * @param node The AST node to check.
     * @throws {Error} (Unreachable)
     * @returns True if the node should be padded, false otherwise.
     */
    function requirePaddingFor(node: ASTNode) {
      switch (node.type) {
        case 'BlockStatement':
        case 'StaticBlock':
          return options.blocks
        case 'SwitchStatement':
          return options.switches
        case 'ClassBody':
          return options.classes

          /* c8 ignore next */
        default:
          throw new Error('unreachable')
      }
    }

    /**
     * Checks the given BlockStatement node to be padded if the block is not empty.
     * @param node The AST node of a BlockStatement.
     */
    function checkPadding(node: Tree.BlockStatement | Tree.SwitchStatement | Tree.ClassBody) {
      const openBrace = getOpenBrace(node)
      const firstBlockToken = getFirstBlockToken(openBrace)
      const tokenBeforeFirst = sourceCode.getTokenBefore(firstBlockToken, { includeComments: true })!
      const closeBrace = sourceCode.getLastToken(node)!
      const lastBlockToken = getLastBlockToken(closeBrace)
      const tokenAfterLast = sourceCode.getTokenAfter(lastBlockToken, { includeComments: true })!
      const blockHasTopPadding = isPaddingBetweenTokens(tokenBeforeFirst, firstBlockToken)
      const blockHasBottomPadding = isPaddingBetweenTokens(lastBlockToken, tokenAfterLast)

      if (options.allowSingleLineBlocks && isTokenOnSameLine(tokenBeforeFirst, tokenAfterLast))
        return

      // default report
      const report = {
        node,
        loc: {
          end: tokenAfterLast.loc.start,
          start: lastBlockToken.loc.end,
        },
      }

      // return value undefined | always | never| start | end
      switch (requirePaddingFor(node)) {
        case 'always':
          !blockHasTopPadding && addStartPad(report, tokenBeforeFirst)
          !blockHasBottomPadding && addEndPad(report, tokenAfterLast)
          break;
        case 'never':
          blockHasTopPadding && removeStartPad(report, tokenBeforeFirst, firstBlockToken)
          blockHasBottomPadding && removeEndPad(report, lastBlockToken, tokenAfterLast)
          break;
        case 'start':
          !blockHasTopPadding && addStartPad(report, tokenBeforeFirst)
          break;
        case 'end':
          !blockHasBottomPadding && addEndPad(report, tokenAfterLast)
          break;
      }
    }

    function addStartPad(report, tokenBeforeFirst) {
      report.fix = (fixer) => fixer.insertTextAfter(tokenBeforeFirst, '\n')
      report.messageId = 'startPadBlock'
      context.report(report)
    }

    function addEndPad(report, tokenAfterLast) {
      report.fix = (fixer) => fixer.insertTextBefore(tokenAfterLast, '\n')
      report.messageId = 'endPadBlock'
      context.report(report)
    }

    function removeStartPad(report, tokenBeforeFirst, firstBlockToken) {
      report.fix = (fixer) => fixer.replaceTextRange([tokenBeforeFirst.range[1], firstBlockToken.range[0] - firstBlockToken.loc.start.column], '\n')
      report.messageId = 'startNoPadBlock'
      context.report(report)
    }

    function removeEndPad(report, lastBlockToken, tokenAfterLast) {
      report.fix = (fixer) => fixer.replaceTextRange([lastBlockToken.range[1], tokenAfterLast.range[0] - tokenAfterLast.loc.start.column], '\n')
      report.messageId = 'endNoPadBlock'
      context.report(report)
    }

    const rule: Record<string, any> = {}

    if (Object.prototype.hasOwnProperty.call(options, 'switches')) {
      rule.SwitchStatement = function (node: Tree.SwitchStatement) {
        if (node.cases.length === 0)
          return

        checkPadding(node)
      }
    }

    if (Object.prototype.hasOwnProperty.call(options, 'blocks')) {
      rule.BlockStatement = function (node: Tree.BlockStatement) {
        if (node.body.length === 0)
          return

        checkPadding(node)
      }
      rule.StaticBlock = rule.BlockStatement
    }

    if (Object.prototype.hasOwnProperty.call(options, 'classes')) {
      rule.ClassBody = function (node: Tree.ClassBody) {
        if (node.body.length === 0)
          return

        checkPadding(node)
      }
    }

    return rule
  },
})

@antfu antfu added the accepted The propsal get accepted, PR welcome! label Apr 1, 2024
@antfu
Copy link
Member

antfu commented Apr 1, 2024

Sounds good to me, PR welcome!

@erosman
Copy link
Author

erosman commented Apr 3, 2024

Updated code in #308 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
accepted The propsal get accepted, PR welcome! enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants