openrewrite / openrewrite/rewrite

TabsAndIndents (java): indent-normalisation trips over mixed indents.

Open
#7,579 7 comments 0 reactions 2 assignees View on GitHub

@juleskers is already working on this.

Since May 22, 2026.

documentation
Dominant language
Java
Stars
3.7k
Forks
570
Avg merge
13h 12m
Merged PRs (30d)
261

Description

Hi, and thank you for making this tool!
It's great to see dependable, deterministic machinery in this age of "just LLM it, bro".
I have much more trust in machine-transformations than vague, expensive GPU magic.


I'm exploring OpenRewrite to get a handle on a large-ish (80k lines) java/maven multimodule project.

As a toy example / getting my feet wet, I'm normalising the (partially cursed) mixed indents spread in the ancient parts.
The ancient parts mix tabs, spaces, tabs-then-spaces, spaces-then-tabs and most cursed of all: spaces-tab-spaces-tab, depending on which author copy-pasted which snippet in which IDE (through several copy-paste iterations).

I've standardised the three current devs on .editorconfig, choosing tab-indents, stopping future madness.

  • (I saw related #3666, though I don't think it affects this situation).

Applying the org.openrewrite.java.format.NormalizeTabsOrSpaces recipe gets me most of the way there.
However: a few of the cursed situations are mishandled, resulting in lost indent-levels and weird-looking code.

I didn't apply any configuration to the recipe, so I'm unsure if autodetection got it right, or if a config somewhere got picked up.
"normal" 4-space indented files got handled without a hitch, so whatever the source, the style matches what I want.

What version of OpenRewrite are you using?

I am using

  • Maven plugin 6.38.0
  • OpenRewrite v??
  • rewrite-java v??

How are you running OpenRewrite?

I am using the Maven plugin, and my project is a multi module project.
The config is applied only in the project-parent, delegating most of the actual config to a top-level rewrite.yml.

<!-- in multi-module parent; nothing in child modules -->
<plugin>
	<groupId>org.openrewrite.maven</groupId>
	<artifactId>rewrite-maven-plugin</artifactId>
	<version>6.38.0</version>
	<configuration>
		<exportDatatables>false</exportDatatables>
		<activeRecipes>
			<recipe>com.example.whitespace-pedantry</recipe>
		</activeRecipes>
	</configuration>
</plugin>
# project-root/rewrite.yml
---
type: specs.openrewrite.org/v1beta/recipe
name: com.example.whitespace-pedantry
recipeList:
  - org.openrewrite.java.format.RemoveTrailingWhitespace
  - org.openrewrite.java.format.NormalizeTabsOrSpaces
  - org.openrewrite.java.format.EmptyNewlineAtEndOfFile

What is the smallest, simplest way to reproduce the problem?

The clearest example I could find was 'normal' tab-indentation
except for the first column (which used 4-spaces instead of tab).

I've created the following example class to demonstrate all issues I found (And some additional natural variations).
Including this in a random position in my codebase gets the same mis-indenting applied as the real code.

I'm not sure how to get the github code view to show whitespace, but copy-pasting it into a full editor should reveal the cursed-ness. (or drag-selecting in the indents; note the jumps)

For reference, this is intended to be tabwidth=4. (though the form-entry field seems to use tab=8)
It mixes all permutations of spaces, tabs, tab-then-spaces, spaces-then-tabs as well as spaces-tab-spaces, tabs-spaces-tabs and even tab-spaces-tab-space and spaces-tab-spaces-tab.
You'd think this overkill, but I did actually have a real spaces-tab-spaces-tab case in the code-base.

(Don't get me started on the """text-blocks"""... Those are equally cursed, but correctly untouched by this recipe)

public class CursedIndents {
	public void iStartWithAllTabs() {
		System.out.println("\t is cool");
		if (true) {
			System.out.println("3 tabs are cooler");
			if (false) {
				System.out.println("nesting-ception");
			}
		}
	}

    public void iStartWithAllSpaces() {
        System.out.println("space is cool");
        if (true) {
            System.out.println("12 spaces are cooler");
            if (false) {
                System.out.println("nesting-ception");
            }
        }
    }

	public void tabsThenSpaces() {
	    System.out.println("mixing is cool");
	    if (true) {
	        System.out.println("more spaces is more cool");
	        if (false) {
	            System.out.println("nesting-ception");
	        }
	    }
	}

    public void spacesThenTabs() {
    	System.out.println("\t is cool");
    	if (true) {
    		System.out.println("3 tabs are cooler");
    		if (false) {
    			System.out.println("nesting-ception");
    		}
    	}
    }

	public void trulyCursed() {
    	System.out.println("\t is cool");
	    if (true) {
	    	System.out.println("the mixing, it pains me.");
    	    if (false) {
	    	    System.out.println("multi-mixing, tabs-first");
    	    	System.out.println("multi-mixing, spaces-first");
			}
    	}
	}
}

What did you expect to see?

I had hoped all 4-spaces would become 1 tab, regardless of their position in the cursed mix.
As in: Scoping the replacement to the entire leading whitespace-mix, replacing all occurrences.
Instead multiple "boundaries" seem to mess up the result.

public class NoLongerCursedIndents {
	public void iStartWithAllTabs() {
		System.out.println("\t is cool");
		if (true) {
			System.out.println("3 tabs are cooler");
			if (false) {
				System.out.println("nesting-ception");
			}
		}
	}

	public void iStartWithAllSpaces() {
		System.out.println("space is cool");
		if (true) {
			System.out.println("12 spaces are cooler");
			if (false) {
				System.out.println("nesting-ception");
			}
		}
	}

	public void tabsThenSpaces() {
		System.out.println("mixing is cool");
		if (true) {
			System.out.println("more spaces is more cool");
			if (false) {
				System.out.println("nesting-ception");
			}
		}
	}

	public void spacesThenTabs() {
		System.out.println("\t is cool");
		if (true) {
			System.out.println("3 tabs are cooler");
			if (false) {
				System.out.println("nesting-ception");
			}
		}
	}

	public void trulyCursed() {
		System.out.println("\t is cool");
		if (true) {
			System.out.println("the mixing, it pains me.");
			if (false) {
				System.out.println("multi-mixing, tabs-first");
				System.out.println("multi-mixing, spaces-first");
			}
		}
	}
}

What did you see instead?

  • ✔️ 4-spaces gets turned into 1-tab
    • other classes with 8-spaces, 12-spaces, 16-spaces correctly turn into 2/3/4 tabs.
  • 💣 4-spaces, then-tab, then code/end-of-whitespace becomes one tab (rather than two, as expected)
    in effect, "eating" the 4-spaces without replacing them with anything? (or replacing the four spaces, but eating the original tab?)
  • 💣 4-spaces, then-tab, then more tabs becomes "more tabs".
    in effect, losing two indent-levels (3->1, 4->2)
public class CursedIndents {
	public void iStartWithAllTabs() {
		System.out.println("\t is cool");
		if (true) {
			System.out.println("3 tabs are cooler");
			if (false) {
				System.out.println("nesting-ception");
			}
		}
	}

	public void iStartWithAllSpaces() {
		System.out.println("space is cool");
		if (true) {
			System.out.println("12 spaces are cooler");
			if (false) {
					System.out.println("nesting-ception");
			}
		}
	}

	public void tabsThenSpaces() {
		System.out.println("mixing is cool");
		if (true) {
			System.out.println("more spaces is more cool");
			if (false) {
					System.out.println("nesting-ception");
			}
		}
	}

	public void spacesThenTabs() {
	System.out.println("\t is cool");
	if (true) {
	System.out.println("3 tabs are cooler");
	if (false) {
		System.out.println("nesting-ception");
	}
	}
	}

	public void trulyCursed() {
	System.out.println("\t is cool");
		if (true) {
		System.out.println("the mixing, it pains me.");
		if (false) {
				System.out.println("multi-mixing, tabs-first");
			System.out.println("multi-mixing, spaces-first");
			}
	}
	}
}

What is the full stack trace of any errors you encountered?

No errors reported by this recipe, even when running with --errors

Are you interested in contributing a fix to OpenRewrite?

I like paying it forward, but am daunted by the IDE setup requirements.
"just ./gradlw build" didn't cut it, IntelliJ can't figure it out either.
I don't plan on setting up multiple JDKs on my work machine just for this.

I've tried making a test-case out of this to open as PR, but gave up battling gradlew after half an hour of it complaining it can't find the proper java-8 on my machine. Blocking modules in ide.properties lead to missing dependency-errors, in gradle build scripts, and I didn't summon the energy to reverse-engineer the module dependency network.
(which quickly lead back to gradle complaining I have no java-8)

I hope this minimal reproducer can be easily copy-pasted by someone with a functioning IDE setup.
I'll gladly review code, brainstorm fixes and engage in other ways that don't require a working IDE.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.