Skip to content

Add switch-case-break-position rule#2910

Merged
sindresorhus merged 6 commits into
sindresorhus:mainfrom
costajohnt:rule/switch-case-break-position
Mar 27, 2026
Merged

Add switch-case-break-position rule#2910
sindresorhus merged 6 commits into
sindresorhus:mainfrom
costajohnt:rule/switch-case-break-position

Conversation

@costajohnt
Copy link
Copy Markdown
Contributor

Closes #2850

Adds a new rule that enforces terminating statements (break, return, continue, throw) appear inside the block statement of a case clause, not after it.

This can happen when refactoring — for example, removing an if wrapper but leaving the break outside the braces.

What the rule catches

// ❌ Bad
switch(foo) {
	case 1: {
		doStuff();
	}
	break;
}

// ✅ Good
switch(foo) {
	case 1: {
		doStuff();
		break;
	}
}

Details

  • Detects break, return, continue, and throw after block statements in case/default clauses
  • Auto-fixable (--fix) — moves the statement inside the block
  • Skips autofix when comments exist between the block and the terminating statement (to avoid data loss)
  • Skips empty block bodies (not fixable)
  • Handles labeled break/continue correctly
  • Enabled in the recommended config, disabled in unopinionated

Test coverage

18 test cases (8 valid, 10 invalid) covering:

  • All four terminating statement types
  • case and default clauses
  • Block on its own line
  • Multiple cases (only flagging the bad one)
  • Labeled break
  • Comment between block and break (fix skipped)
  • Empty block with break after (not flagged)

@sindresorhus
Copy link
Copy Markdown
Owner

A few things:

  1. The fixer can move trailing comments from the last statement onto the inserted terminator. For example, this:
switch (foo) {
	case 1: {
		foo(); // keep with foo
	}
	break;
}

autofixes to:

switch (foo) {
	case 1: {
		foo();
		break; // keep with foo
	}

}

That changes comment attachment and potentially meaning, so I do not think this fixer is safe as is. It should probably either insert before the closing brace or bail when the last statement has trailing comments.

  1. The fixer also breaks formatting for single line block cases, and the current snapshots already show that it leaves an extra blank line after the case block. For example:
switch (foo) { case 1: { foo(); } break; }

autofixes to:

switch (foo) { case 1: { foo();
 break; } }

So even in cases without comments, the fix currently produces malformed or at least pretty rough output.

}
`,
],
});
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
});
});
test({
valid: [],
invalid: [
// Inline comment
{
code: outdent`
switch(foo) {
case 1: {
doStuff(); // Keep this comment with the statement
}
break;
}
`,
output: outdent`
switch(foo) {
case 1: {
doStuff(); // Keep this comment with the statement
break;
}
}
`,
errors: [error],
},
// Block comment
{
code: outdent`
switch(foo) {
case 1: {
doStuff(); /* Keep this block comment with the statement */
}
break;
}
`,
output: outdent`
switch(foo) {
case 1: {
doStuff(); /* Keep this block comment with the statement */
break;
}
}
`,
errors: [error],
},
// Before closing brace
{
code: outdent`
switch(foo) {
case 1: {
doStuff();
// Keep this comment before the inserted break
}
break;
}
`,
output: outdent`
switch(foo) {
case 1: {
doStuff();
// Keep this comment before the inserted break
break;
}
}
`,
errors: [error],
},
// ESLint directive
{
code: outdent`
switch(foo) {
case 1: {
console.log(foo); // eslint-disable-line no-console
}
break;
}
`,
output: outdent`
switch(foo) {
case 1: {
console.log(foo); // eslint-disable-line no-console
break;
}
}
`,
errors: [error],
},
],
});

import outdent from 'outdent';
import {getTester} from './utils/test.js';

const {test} = getTester(import.meta);
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const {test} = getTester(import.meta);
const {test} = getTester(import.meta);
const error = {messageId: 'switch-case-break-position'};

@sindresorhus
Copy link
Copy Markdown
Owner

It's unclear if you have handled all the feedback.

costajohnt and others added 4 commits March 25, 2026 16:01
- Insert before closing brace (via getTokenBefore with includeComments)
  instead of after lastBodyStatement to preserve comment attachment
- Skip fix for single-line blocks to avoid malformed output
- Use removeRange instead of replaceNodeOrTokenAndSpacesBefore to
  eliminate extra blank lines in fixed output
- Add comment-handling test cases and single-line block guard test
@costajohnt costajohnt force-pushed the rule/switch-case-break-position branch from c1ce162 to 9467d5d Compare March 25, 2026 23:01
@costajohnt
Copy link
Copy Markdown
Contributor Author

All four items from the feedback have been addressed:

  1. Trailing comment handling: the fixer checks for comments between the block's closing brace and the terminating statement and skips the fix when any are present. The insertTextAfter(lastTokenBeforeBrace) approach inserts before the closing brace so comment attachment is preserved in the no-comment path.

  2. Single-line block fixer: bails out when blockLoc.start.line === blockLoc.end.line.

  3. Shared error constant added after getTester.

  4. Test file split into test.snapshot() for main cases and a separate test() block with explicit code/output pairs for the comment-handling scenarios (inline comment, block comment, comment before closing brace, ESLint directive).

@sindresorhus
Copy link
Copy Markdown
Owner

I rechecked this manualy and the fixer is still unsafe here.

With:

switch(foo) {
	case 1: {
		doStuff();
	}
	break; // keep with break
}

it fixes to:

switch(foo) {
	case 1: {
		doStuff();
		break;
	} // keep with break
}

So the inline comment still gets detached from the terminator and ends up after the closing brace instead. The fixer only removes through the end of the break node, not through trailing same-line comments, so it is still changing comment attachment and potentially meaning. I think the fix should either bail on same-line trailing comments on the terminator too, or move that full trailing range together with the terminator.

@sindresorhus
Copy link
Copy Markdown
Owner

I would add these tests too:

  • A fixer test where break has a same-line trailing line comment:
switch(foo) {
	case 1: {
		doStuff();
	}
	break; // keep with break
}

This is the clearest regression case.

  • The same, but with a block comment:
switch(foo) {
	case 1: {
		doStuff();
	}
	break; /* keep with break */
}
  • One equivalent case for another terminator, ideally return:
function foo() {
	switch(bar) {
		case 1: {
			doStuff();
		}
		return value; // keep with return
	}
}

That proves the bug is not break-specific.

  • A no-fix test asserting it bails when the terminator has trailing same-line comments, if that is the chosen fix.

Less important, but still decent:

  • continue label; // comment
  • throw error; // comment

costajohnt and others added 2 commits March 26, 2026 19:28
Skip the autofix when break/return/continue/throw has a trailing
same-line comment, to avoid detaching it from the terminator.
Add test cases for line comment, block comment, and return.
@sindresorhus sindresorhus merged commit 8d5d487 into sindresorhus:main Mar 27, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rule proposal: switch-case-break-position

2 participants