Java Save Actions Plus
1. Summary
- What it is: a custom VS Code extension that adds extra Java save-time cleanup, on top of what Red Hat's Java extension already does natively — aimed at closing the gap with STS/Eclipse's save-action behavior.
- Why it exists: STS (Spring Tool Suite) gives developers dozens of automatic save-time cleanups via Eclipse's JDT framework. VS Code doesn't ship an equivalent out of the box. This project closes part of that gap.
- What works today: native Red Hat Java cleanup (configured via settings) plus 4 custom text-level save rules running on every Ctrl+S.
- What doesn't work yet: full STS parity. The harder cleanups need real type/AST information our text rules don't have. Also: installing this into normal managed company VS Code is blocked by policy — it runs via Extension Development Host (F5) or portable VS Code instead.
- Bottom line: the POC proves the mechanism works and is fast enough for real use. It is not, and does not claim to be, a full Eclipse replacement.
2. Problem Statement
- Our team works in both STS and VS Code.
- STS includes Eclipse's full save-action framework — on every Ctrl+S, Eclipse can run dozens of configurable cleanup rules backed by the real JDT AST (type-aware, not just text pattern matching).
- VS Code does not ship an equivalent mechanism by default.
- The Red Hat Java extension (
redhat-developer/vscode-java) brings real JDT language services into VS Code, including a documented subset of cleanup actions — 20 confirmed actions, verified directly against the extension's own document/_java.learnMoreAboutCleanUps.md file. That's real coverage, but it doesn't match Eclipse's full catalog, and it doesn't run on save unless explicitly configured.
- This gap creates friction for developers switching between STS and VS Code. This project closes part of that gap with a custom extension layered on top of the native coverage.
3. What I Tried
3.1 Native Red Hat cleanup route
Configured java.cleanup.actions + editor.codeActionsOnSave to run Red Hat's built-in cleanups automatically on save.
Result: real value, 20 confirmed actions, but not full STS parity — many Eclipse cleanup types aren't exposed through this list at all.
Pointed VS Code's Java formatter at an Eclipse-exported formatter XML profile (same one STS can use), via java.format.settings.url.
Result: this controls code style (indentation, line breaks, spacing) — it's a separate subsystem from cleanup actions in both Eclipse and VS Code. Useful for formatting parity, but doesn't touch the cleanup gap at all (unused imports, annotations, lambda conversion, etc. are handled elsewhere).
3.3 OpenRewrite / external-process route
Tried triggering OpenRewrite cleanup recipes on save via a PowerShell watcher and a Gradle/Maven plugin hook.
Result: technically interesting, explicitly rejected for save-time use:
- Too slow — spawning a Gradle/Maven process or JVM on every Ctrl+S adds multi-second latency.
- Too fragile — project-root detection, wrapper invocation, and merging results back into the editor needed too much glue.
- Not ruled out forever, just explicitly out of scope for phase-1.
3.4 v1 custom extension:
Built a working extension from scratch. Hooked into onDidSaveTextDocument, read file text, applied custom rules, wrote cleaned text back via WorkspaceEdit. Proved the entire mechanism works, with 4 working rules:
- Remove trailing whitespace
- Remove obvious literal casts (
(String) "abc", (int) 5, (long) 10L)
- Wrap single-line control bodies in braces (
if, for, while)
- Diamond operator simplification (
new ArrayList<String>() → new ArrayList<>())
These are text-level rules, not JDT parity — but they work reliably.
3.5 v2 : build using the documented code from eclipse and red hat
v1 was a working but unstructured proof of concept — pipeline logic, rules, and config mixed together in a way that's hard to extend safely. v2 restructures the same proven mechanism into clean modules (native docs, pipeline, rules, text rules, research/gap-analysis) so the team can actually maintain and extend it. v2 is not a rewrite of history — it's the same proven approach, organized properly.
3.6 failed workaround that i tried
Installing a custom .vsix into managed company VS Code is blocked by extension allowlist policy. Two confirmed workarounds:
- Portable VS Code: a separate downloaded VS Code instance, configured independently — no policy interaction, works for install + demo.
- Extension Development Host (F5): the standard developer test workflow — launches a real VS Code window with the extension pre-loaded, no install step needed at all.
F5 is the recommended path for day-to-day dev and demos — no separate install, fastest iteration loop.
4. Current State Today
- v1 = the working fallback POC. Proves the save pipeline + 4 text rules work end to end. Do not delete it. If v2 ever breaks, v1 still works.
- v2 = the cleaner, structured extension — this is what the rest of this doc covers, and what should be used for ongoing work and demos.
- Currently working in v2: extension activation, save listener, all 4 custom rules (trailing whitespace, literal casts, diamond operator, control statement blocks), native Red Hat cleanup running alongside, and save-loop protection.
- Not yet implemented in v2: any cleanup beyond the 4 proven rules; any AST-backed rule; any rule requiring real type information.
5. How to Set Up and Run v2 on Another Laptop
Prerequisites
- VS Code installed
- Node.js 18+ and npm
- The
kb-java-save-actions-v2 repo checked out locally
- Red Hat's Extension Pack for Java installed in VS Code (native cleanup + Java language support)
Steps
- Open the
kb-java-save-actions-v2 folder in VS Code.
- Run:
npm install
npm run compile
- Press F5.
- A new window opens — the Extension Development Host. It's a real VS Code window with the extension active inside it — treat it as a normal editor, not a sandbox.
- In that new window: open a real Java project (File → Open Folder).
- Open a
.java file, make a small messy edit, press Ctrl+S.
- Check the output: View → Output, pick Java Save Actions Plus from the dropdown.
Enable native cleanup (one-time, per project or globally)
{
"java.cleanup.actions": [
"organizeImports",
"addOverride",
"addFinalModifier",
"qualifyMembers",
"qualifyStaticMembers",
"lambdaExpressionFromAnonymousClass",
"lambdaExpression",
"redundantModifiers",
"redundantSuperCall"
],
"editor.codeActionsOnSave": {
"source.cleanup.java": "explicit"
}
}
Adjust to taste — some actions (like qualifyMembers) are style preferences, not universal.
Package as VSIX (for portable VS Code demo, not managed VS Code)
npm install -g @vscode/vsce
vsce package
code --install-extension java-save-actions-plus-1.2.0.vsix
Install into a portable VS Code instance:
code --install-extension java-save-actions-plus-1.2.0.vsix
Common limitations
| Gotcha |
Fix |
| Testing in the wrong window |
Edit Java files in the Extension Development Host window, not your original VS Code window |
| Output panel shows nothing |
Dropdown defaults to Tasks — switch it to Java Save Actions Plus |
| Save seems to "hang" |
A breakpoint is probably still set in the source and execution is paused — check the original (debugger) window |
| Compile errors after editing a rule |
Usually a mismatched import/export name — check the rule file's export matches what rulesRegistry.ts imports |
| Can't install in normal VS Code |
Expected — managed company VS Code blocks custom VSIX installs by policy. Use F5 or portable VS Code instead. |
6. How v2 Works Internally
On Java file save, in order:
- File is saved (Ctrl+S, or a save triggered by another tool).
- The save listener in
pipeline/savePipeline.ts receives the event.
- It checks: is this a real user save, or one our own extension just triggered? (Save-loop guard — Section 9.) If it's ours, skip.
- Config is checked (
src/config.ts) — is the pipeline enabled? Are custom rules enabled?
- Active rules are pulled from
rules/rulesRegistry.ts.
rules/ruleRunner.ts runs each active rule, in order, over the file's text.
- If anything changed,
pipeline/documentRefresh.ts applies the new content back to the editor and saves it.
- That save is marked internally so it doesn't re-trigger step 2 (the loop guard).
- Everything logs to the Java Save Actions Plus output channel.
File / folder reference
| File / folder |
Purpose |
When you touch it |
src/extension.ts |
Entry point, activation, command registration |
Rarely — new commands or activation changes |
src/config.ts |
Reads extension settings |
Adding a new setting |
src/logger.ts |
Output channel logging |
Rarely |
pipeline/savePipeline.ts |
Save listener, debounce, save-loop guard, orchestration |
Changing save behavior or fixing pipeline bugs |
pipeline/documentRefresh.ts |
Applies cleaned content back to file/editor; includes the active-editor-mismatch guard (modeled on vscode-java issue #557) |
Changing how edits are applied |
rules/rulesTypes.ts |
The KbRule interface (id, label, apply()) every rule must implement |
Only if the rule contract itself changes |
rules/ruleRunner.ts |
Runs the active rules in order, catches errors per-rule |
Rarely |
rules/rulesRegistry.ts |
Which rules are active |
Every time you add or remove a rule |
text/*.ts |
The actual custom save-action rules |
Adding/fixing a rule |
native/nativeCleanupMap.ts |
Documents which actions Red Hat already covers natively |
Confirming/updating native coverage |
research/stsActionMap.ts |
Full Eclipse/JDT cleanup taxonomy with coverage tier (native / kb-text / gap / uncertain) |
Mapping a new STS action to a strategy |
package.json |
Extension manifest, commands, settings, scripts |
Adding commands/settings, version bump |
tsconfig.json |
TypeScript build config |
Rarely |
README.md |
Repo-level overview |
Keep in sync with Section 7 below |
7. Native Red Hat Coverage vs KB Custom Rules
7.1 Native Red Hat cleanup actions
Verified directly against redhat-developer/vscode-java's own document/_java.learnMoreAboutCleanUps.md — 20 confirmed action IDs, no more, no less. Do not re-implement any of these.
| Native ID |
What it does |
qualifyMembers |
Adds this. to unqualified field/method access |
qualifyStaticMembers |
Qualifies static access with the declaring class name |
addOverride |
Adds missing @Override (including interface implementations) |
addDeprecated |
Adds missing @Deprecated when Javadoc has @deprecated |
stringConcatToTextBlock |
Converts qualifying String concatenations to text blocks (Java 15+) |
invertEquals |
Inverts .equals()/.equalsIgnoreCase() calls to avoid NPE risk |
addFinalModifier |
Adds final to variables/params/fields where legal |
instanceofPatternMatch |
Converts instanceof + cast to pattern matching (Java 15+) |
lambdaExpressionFromAnonymousClass |
Converts anonymous functional-interface class to lambda (Java 8+) |
switchExpression |
Converts switch statements to switch expressions (Java 14+) |
tryWithResource |
Converts try/finally-close to try-with-resources |
lambdaExpression |
Simplifies lambda syntax (removes unneeded parens, converts to method ref, etc.) |
organizeImports |
Organizes/removes unused imports |
renameUnusedLocalVariables |
Renames unused loop/lambda/pattern variables to _ |
useSwitchForInstanceofPattern |
Converts instanceof if/else chains to pattern-matching switch |
redundantComparisonStatement |
Removes redundant comparison statements |
redundantFallingThroughBlockEnd |
Removes redundant end-of-block jump statements |
redundantIfCondition |
Simplifies redundant if/else-if conditions |
redundantModifiers |
Removes redundant modifiers (e.g. public on interface members) |
redundantSuperCall |
Removes a no-op super() call in a constructor |
Configure via:
{
"java.cleanup.actions": ["organizeImports", "addOverride", "..."],
"editor.codeActionsOnSave": { "source.cleanup.java": "explicit" }
}
7.2 Custom v2 rules (phase-1, text-level)
| Rule ID |
File |
What it does |
Scope |
kb.trailingWhitespace |
text/trailingWhitespaceRule.ts |
Removes trailing whitespace per line |
All lines |
kb.unnecessaryLiteralCast |
text/unnecessaryLiteralCastRule.ts |
Removes obvious redundant casts on literals, e.g. (String) "abc" |
Literal-only — does not attempt general cast removal |
kb.controlStatementBlock |
text/controlStatementBlockRule.ts |
Wraps a bare if (...)/for (...)/while (...) followed by a single next-line statement into a { } block |
Common single-line case only |
kb.diamondOperator |
text/diamondOperatorRule.ts |
new ArrayList<String>() → new ArrayList<>() |
No-arg constructors with an explicit generic type only |
These are text-level pattern-matching rules, not real Java parsing. They don't understand types, scope, or semantics — only text shape. They're deliberately conservative: when a case looks ambiguous, the rule does nothing rather than risk an incorrect edit.
7.3 Known coverage gaps
These are confirmed to exist in Eclipse's cleanup framework but are not yet implemented in v2:
| Gap |
Why it's not in phase-1 |
| StringBuilder/StringBuffer conversion |
Correct behavior needs type/loop context — not text-safe (Eclipse reference: StringFixCore.java) |
| Enhanced-for-loop conversion |
Requires iterable type checking — not text-safe |
| Lazy logical operator substitution |
Type-sensitive — needs real AST |
| Static inner class detection |
Needs scope/capture analysis — high difficulty |
| Primitive cleanup family (comparison/parsing/wrapper-vs-primitive) |
All type-resolution-dependent — do not attempt as text rules |
Boolean literal simplification (full Boolean.TRUE/Boolean.FALSE case) |
The narrow == true/== false case is text-safe; the full wrapper-type case needs type info |
| Unnecessary cast removal (general, beyond literals) |
Needs real type resolution (Eclipse reference: UnusedCodeFixCore.java) — only the literal-only case is text-safe |
8. Design Principles in v2
v1 proved the mechanism works but mixed pipeline logic, rules, and config into a small number of files — workable for a proof of concept, hard to extend safely as a team. v2 keeps the exact same proven approach, restructured around these principles:
- Don't rebuild what Red Hat already gives us. If an action is in
java.cleanup.actions, configure it — don't custom-code it.
- Text rules are honest about scope. Every rule has a documented covered/not-covered boundary. No rule claims more than its text pattern can provably guarantee.
- Eclipse JDT is the behavioral reference. For every rule, the corresponding Eclipse fix class is the source of truth for intended behavior.
- Uncertain things are marked uncertain, not silently assumed correct.
- Phase-1 is fast and in-process. No external JVM, no Gradle, no OpenRewrite on every save.
- v1 is the fallback. Don't delete it.
9. Current Limitations / Known Gaps
- KB custom rules are text-level, not AST-based — no understanding of types, scope, or semantics.
- Not every STS/Eclipse save action is implemented — only the safe, provable wins so far (Section 7.3 lists the confirmed gaps).
- Native coverage and KB custom coverage live in two separate systems — there's no single switch showing "everything that happens on save." Check both Section 7.1 and your
settings.json.
- Managed company VS Code blocks normal
.vsix install. This is a policy constraint, not a bug — F5 / Extension Development Host or portable VS Code are the working paths.
- Save-loop bug (fixed): the extension used to re-trigger itself endlessly after applying its own edit. Fixed with an internal-save guard in
pipeline/savePipeline.ts — when the extension saves a file itself, it marks that save so the resulting onDidSaveTextDocument event is recognized as "ours" and skipped exactly once, instead of re-entering the pipeline.
- Real STS/Eclipse parity on the harder rules would need an AST-based approach — most realistically a small Java helper process using actual JDT parsing, not more text rules. Not built yet.
10. How to Debug It
- Run via F5 (Section 5).
- Watch the Java Save Actions Plus output channel — every step logs there.
- To debug into code: set a breakpoint in any
.ts file, trigger a save in the Extension Development Host window — execution pauses in your original (debugger) window.
- Don't leave breakpoints set for a demo — every save will pause and look broken.
- Rule not firing? Check
rules/rulesRegistry.ts to confirm it's registered and enabled, then check the rule's own matching logic — it may just not match your test input's exact shape.
- Repeated log lines for one save? That's the save-loop bug resurfacing — check that the internal-save guard in
pipeline/savePipeline.ts hasn't been bypassed by a recent edit.
11. How to Maintain / Extend It
Adding a new text-level rule:
- Check
native/nativeCleanupMap.ts (Section 7.1) — is it already native? If yes, stop, just enable the setting.
- Find the corresponding Eclipse fix class for behavioral reference (Section 14 has the confirmed paths).
- Decide: text-safe, or needs an AST helper? If it needs real type/scope info, document it in
research/stsActionMap.ts as a future gap — don't force it into a text rule.
- If text-safe: create the file under
text/, implement the KbRule contract from rules/rulesTypes.ts, register it in rules/rulesRegistry.ts.
- Test: build a fixture input/expected-output pair, run it, confirm idempotence (running the rule twice on clean output makes no further changes).
- Update Section 7.2 of this doc, the README, and
research/stsActionMap.ts.
- Bump the minor version in
package.json — a new rule is always at least a minor bump.
When Red Hat updates vscode-java: re-check document/_java.learnMoreAboutCleanUps.md against native/nativeCleanupMap.ts. If a new native action covers something we built ourselves, remove the KB rule and switch to native — don't run duplicate implementations of the same cleanup.
When Eclipse JDT updates: Eclipse is actively migrating cleanup logic between bundles (confirmed: ControlStatementsFix.java, StringFixCore.java, and UnusedCodeFixCore.java all currently live in org.eclipse.jdt.core.manipulation, not org.eclipse.jdt.ui, where some older references might assume). Re-check the specific fix class for any rule you've ported when upgrading your reference checkout — file locations have moved before and may move again.
Versioning policy:
- New rule or behavior change → minor bump
- Bug fix in an existing rule → patch bump
- Breaking architectural change → major bump
What this extension does not claim
- Don't claim the text rules are equivalent to Eclipse's AST-backed versions.
- Don't claim full STS parity — this is progressive improvement, not 100%.
- Don't claim it works in managed VS Code without the F5/portable workaround.
How to handle limitations honestly without killing the demo
Frame every gap as "known and tracked," not "broken." The research/gap-analysis layer exists specifically so this framing is true, not just a talking point.
12. Future Scope
- Add more custom rules, but only where native Red Hat cleanup doesn't already cover the case — check Section 7.1 first, every time.
- Build a small fixture-test set (input/expected-output Java files) per rule, so testing isn't just "save something messy and eyeball it."
- Keep Section 7's native-vs-custom mapping current as Red Hat adds more cleanup actions over time.
- For real STS parity on the harder rules: evaluate an AST-backed approach — most realistically a small Java helper process using actual JDT parsing, invoked per-file (not a whole-module OpenRewrite-style run, to avoid reintroducing the speed problem from Section 3.3).
- Decide, as a team, whether v2 stays a dev/demo tool or becomes a supported internal extension — that decision is blocked on the company VS Code extension policy question, not on the code.
13. References
All paths below were verified directly against a real checkout of each repo — not assumed from memory.
| Source |
What it grounds |
redhat-developer/vscode-java — document/_java.learnMoreAboutCleanUps.md |
The authoritative list of all 20 native cleanup action IDs. Every ID in Section 7.1 was counted and verified directly against this file. |
redhat-developer/vscode-java — issue #557 |
Active-editor-mismatch guard pattern, mirrored in pipeline/documentRefresh.ts. |
eclipse-jdt/eclipse.jdt.ui — org.eclipse.jdt.ui/core extension/org/eclipse/jdt/internal/corext/fix/CleanUpPostSaveListener.java |
Eclipse's own "run cleanups on save" orchestration — the closest real-world equivalent of pipeline/savePipeline.ts. Confirmed still located in the org.eclipse.jdt.ui bundle. |
eclipse-jdt/eclipse.jdt.ui — org.eclipse.jdt.ui/core extension/org/eclipse/jdt/internal/corext/fix/CleanUpConstantsOptions.java |
Confirmed still in org.eclipse.jdt.ui. |
eclipse-jdt/eclipse.jdt.ui — org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/fix/CleanUpConstants.java |
Source of truth for Eclipse's cleanup constant names/catalog. Confirmed located in org.eclipse.jdt.core.manipulation, not org.eclipse.jdt.ui — this bundle is the headless-capable split-out of cleanup logic, an important distinction if tracing source paths yourself. |
eclipse-jdt/eclipse.jdt.ui — org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/fix/ControlStatementsFix.java |
Behavioral reference for kb.controlStatementBlock. Confirmed in org.eclipse.jdt.core.manipulation. |
eclipse-jdt/eclipse.jdt.ui — org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/fix/StringFixCore.java |
Reference for StringBuilder/StringBuffer conversion (Section 7.3 gap). Confirmed in org.eclipse.jdt.core.manipulation. |
eclipse-jdt/eclipse.jdt.ui — org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/fix/UnusedCodeFixCore.java |
Reference for unnecessary cast detection (Section 7.3 gap). Confirmed in org.eclipse.jdt.core.manipulation. |
eclipse-jdt/eclipse.jdt.ui — org.eclipse.jdt.core.manipulation/common/org/eclipse/jdt/internal/ui/fix/RedundantModifiersCleanUp.java |
Source confirmation for the redundantModifiers native action's Eclipse-side equivalent. Confirmed in org.eclipse.jdt.core.manipulation. |
| |