Convert an OpenRewrite recipe to a JDT refactoring
Background
Every Java quick fix in spring-boot-language-server shares one diagnostic/CodeAction
pipeline (boot/java/reconcilers/JdtAstReconciler → ReconcileProblemImpl →
SimpleLanguageServer.createProblemCollector → LSP Diagnostic/CodeAction/
executeCommand → QuickfixRegistry). Only the fix descriptor and its execution
engine differ between the two mechanisms:
| OpenRewrite (old) | JDT (target) | |
|---|---|---|
| Descriptor | FixDescriptor (commons-rewrite/.../java/FixDescriptor.java) |
JdtFixDescriptor (boot/java/jdt/refactoring/JdtFixDescriptor.java) |
| Quickfix type id | RewriteRefactorings.REWRITE_RECIPE_QUICKFIX |
JdtRefactorings.JDT_QUICKFIX |
| Fix logic | org.openrewrite.Recipe subclass; re-parses source with OpenRewrite's own parser |
JdtRefactoring subclass; runs against the same JDT CompilationUnit used for reconciling |
| Scope | RecipeScope.NODE/FILE/PROJECT, generic |
No generic scope concept — a JdtFixDescriptor just lists the docUris to apply to; each refactoring implements its own "multiple targets" story (e.g. int... offsets) |
Workflow
Read the recipe and its test. Recipe lives in
headless-services/commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/java/(ororg/openrewrite/java/spring/...). Its test (siblingsrc/testtree) is the ground truth for the before/after behavior you must preserve.Find the reconciler wiring the recipe. Grep the recipe's class name under
spring-boot-language-server/.../boot/java/reconcilers/. Read how it detects the problem (JDT AST + resolved bindings +AnnotationHierarchies) and whatFixDescriptor(s)/RecipeScope(s) it builds viaReconcileUtils.setRewriteFixes.Design the
JdtRefactoring. Look at existing examples inboot/java/jdt/refactoring/for the closest shape:- Offset-anchored, single or batched fix —
ChangeMethodVisibilityRefactoring,RemoveAnnotationRefactoring,AddAnnotationRefactoring(constructor takes theintoffset(s) the reconciler already found;apply()re-locates the node viaNodeFinder). - Offset-anchored, re-derives related nodes —
ExtractRequestMappingParentPathRefactoring(class + method offsets, matches annotations by simple name). - Self-scanning, no offsets —
MovePathToRequestMappingRefactoring(used when the fix genuinely needs to re-scan a whole compilation unit, e.g. for a "fix all in file" quick fix without pre-collected offsets).
Prefer an offset-based design when the reconciler already found the exact node(s) — it's cheaper and matches most of the codebase. Only self-scan when you truly need to reprocess a whole file.
- Offset-anchored, single or batched fix —
Match by simple name, not resolved bindings, inside the refactoring.
JdtRefactoring.apply()runs with real bindings in production (viaCompilationUnitCache), but this class's own unit tests parse with an empty classpath (seeExtractRequestMappingParentPathRefactoringTest), soresolveTypeBinding()/resolveBinding()returnnullthere. Match annotation/type names viaJdtRefactorUtils.extractSimpleName(...)on the syntactic type name, the way every existingJdtRefactoringdoes — don't rely on resolved bindings inside these classes. (The reconciler, which always runs with a real classpath, is the right place to use bindings/AnnotationHierarchiesfor the actual diagnostic detection — leave that binding-based logic alone.)Constructor must be Gson-serializable and the class concrete/named (not a lambda or anonymous class) — see
JdtRefactoring's javadoc. Only primitives, Strings, ints/offsets,List<String>, and similar simple records/value objects.Reuse
JdtRefactorUtilsfirst. It already hasaddImport,removeImports,toLspTextDocumentEdit,extractSimpleName/extractPackageName,newStringLiteral,markerAnnotationLike,findValueOrPathMemberValuePair. Add a new helper there (not a private method in your new class) if a second refactoring will need it too.Register the new class in
IndexGsonTypeFactories.jdtRefactorings(). This is mandatory, not automatic:RegisteredSubtypesTypeAdapterFactorydeliberately never falls back toClass.forNamewhen deserializing (security hardening against a malicious/buggy LSP client instantiating arbitrary classes), so every concreteJdtRefactoringsubtype needs an explicitregisterSubtype(...)call or deserialization throws.Rewire the reconciler: replace
FixDescriptor/RewriteRefactorings.REWRITE_RECIPE_QUICKFIXwithJdtFixDescriptor/JdtRefactorings.JDT_QUICKFIX(seeBeanMethodNotPublicReconciler.addQuickFixesfor the pattern). Flag these to the user rather than silently deciding:- Project-wide scope — the JDT engine has no built-in "whole project" mode; a
JdtFixDescriptoronly applies to the exactdocUrislisted. None of the already-converted reconcilers offer a project-wide JDT quick fix. Default to dropping it unless told to keep it (which means the reconciler must enumerate every project.javasource file itself and list them all asdocUris). - Detection gaps — some recipes handle annotation forms/cases the reconciler's diagnostic logic never actually detects (e.g. a keyword-form attribute the recipe supports but the reconciler only checks a shorthand form). Ask whether to close such gaps while converting, or preserve bug-for-bug parity with today's behavior.
- Project-wide scope — the JDT engine has no built-in "whole project" mode; a
Old recipe cleanup — only delete the OpenRewrite recipe + its test after grepping for other references and confirming with the user; some recipes may still be used elsewhere.
Tests:
- Port the recipe's test cases into a new unit test for the
JdtRefactoring, mirroringExtractRequestMappingParentPathRefactoringTest: bareASTParser.newParser(AST.JLS25)with an empty environment,ASTRewrite.create(cu.getAST()),apply(rewrite, cu),rewrite.rewriteAST(doc, formatterOptions),edit.apply(doc), then compare the resulting source text. - Update the reconciler's own test (extends
BaseReconcilerTest) — the quick fix count usually changes.
- Port the recipe's test cases into a new unit test for the
Build/verify:
cd headless-services # only if you see "cannot find symbol" from stale local .m2 artifacts: ./mvnw -q -o install -pl spring-boot-language-server -am -DskipTests ./mvnw -q -o test -pl spring-boot-language-server \ -Dtest=<NewRefactoringTest>,<ReconcilerTest> -Dsurefire.failIfNoSpecifiedTests=false ./mvnw -q -o test -pl commons/commons-rewrite # if you removed a recipe/test from this modulePlugin docs — if
claude-plugins/spring-tools/explanations/<ProblemType code>.mdexists for this diagnostic, check its before/after examples still match; usually no change is needed since that file documents observable behavior, not implementation.Copyright header — add/bump the current year in the EPL header of every file you touch, per this repo's root
CLAUDE.md.
Key files
| Purpose | Path |
|---|---|
| JDT refactoring interface | spring-boot-language-server/.../boot/java/jdt/refactoring/JdtRefactoring.java |
| JDT execution engine | .../jdt/refactoring/JdtRefactorings.java |
| JDT fix descriptor | .../jdt/refactoring/JdtFixDescriptor.java |
| Shared JDT AST helpers | .../jdt/refactoring/JdtRefactorUtils.java |
| Gson polymorphic registration | .../boot/index/cache/IndexGsonTypeFactories.java (jdtRefactorings()) |
| Reconciler examples | .../boot/java/reconcilers/*Reconciler.java |
| Old-style OpenRewrite recipes | commons/commons-rewrite/src/main/java/org/springframework/ide/vscode/commons/rewrite/java/ |