Why Regex Fails at Code Migrations and How IntelliJ's Structural Search Fixes It
Regex-based code migrations break on whitespace, comments, and generics. IntelliJ's Structural Search and Replace matches AST patterns instead, enabling precise, language-aware refactorings like Collections.singletonList → List.of with type-aware constraints.
05 Jul 2025, 16:04 UTC

The problem with text-based search
You need to replace every Collections.singletonList(x) call with List.of(x) across a 200-file Java module. A regex like Collections\.singletonList\((.*)\) seems fine until you hit Collections.singletonList( "a" ) with extra spaces, a comment inside the parentheses, or a generic type like Collections.singletonList("a"). Regex operates on characters; it doesn't know Java syntax, so it either misses matches or corrupts code.
IntelliJ IDEA's Structural Search and Replace (SSR) solves this by matching against the Abstract Syntax Tree (AST) — or more precisely, the PSI (Program Structure Interface) that IntelliJ builds for each JVM language. Instead of character sequences, you describe code structure with placeholders that understand types, nesting, and language rules.
How SSR patterns work
An SSR template looks like code but uses $Placeholders$ for variable parts. For the migration above, the search template is:
Collections.singletonList($Argument$)
The replacement template is:
List.of($Argument$)
Each placeholder can carry constraints. Click the gear icon next to $Argument$ in the SSR dialog and you can require:
- Type filter: only match expressions of type
String,Integer, or any non-primitive. - Text regex: further restrict the matched text (e.g., exclude
null). - Occurrence count: match one or more arguments (useful for varargs).
- Minimum/maximum lines: control formatting tolerance.
Because SSR works on PSI nodes, it ignores whitespace, comments, and explicit type arguments like — the placeholder binds to the actual argument expression node, not the surrounding characters.
Worked example: migrating to List.of
Open Edit → Find → Replace Structurally (or press Ctrl+Shift+A / Cmd+Shift+A and type "Replace Structurally"). Paste the search and replacement templates above. Click Find to preview matches across the project.
IntelliJ 2024.2+ ships this exact migration under Existing Templates → Java → Migrate to List.of. The built-in template adds a type constraint on $Argument$ to skip null literals, because List.of(null) throws NullPointerException while Collections.singletonList(null) returns a list containing null. That semantic difference is exactly why AST-aware replacement matters.
Run the preview on a test class containing:
var a = Collections.singletonList("hello");
var b = Collections.singletonList(42);
var c = Collections.singletonList(null);
var d = Collections.singletonList("generic");
Only the first, second, and fourth lines are replaced. The null case is left untouched — preserving runtime behavior. You can inspect each match in the preview pane before committing Replace All.
Sharing, automation, and cross-language notes
Templates are saved as XML. In the SSR dialog, click Export to get a file you can commit to version control. Teammates import it via Import — no manual recreation needed. You can also promote a template to a Structural Search inspection (Settings → Editor → Inspections → Structural Search) so it runs automatically on save or during batch analysis (Code → Analyze Code → Run Inspection by Name).
SSR works for Kotlin, Groovy, and Scala too, but placeholder syntax and PSI node types differ. A Java template for $Expression$ won't match the same Kotlin constructs without adjustment. If you maintain a polyglot codebase, keep language-specific templates in separate directories.
Trade-offs and limitations
- Performance: Deeply nested patterns with many placeholders can lag on large projects. Test on a subset first.
- Version sensitivity: Placeholder constraint options and PSI structure change between IntelliJ major releases. A template exported from 2024.2 may need tweaks in 2025.1.
- No refactoring integration: SSR performs textual replacement on the PSI level. It does not trigger Safe Delete, Rename, or Change Signature refactorings. If the replaced code is referenced elsewhere (e.g., a method renamed inside the matched block), those references won't update automatically — you'll need a follow-up pass.
- Built-in templates aren't auto-updated: New migration templates appear only with IDE upgrades or plugin updates. Check the Existing Templates tab after each major release.
Next step
Open the SSR dialog today and run the built-in Migrate to List.of template on a small package. Verify the preview behaves as expected, then export the template to your repo's .idea/ssr/ folder (create it if missing). Add a one-line README so newcomers know how to import it. That single file turns a fragile regex script into a shareable, reviewable, language-aware migration your whole team can trust.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.