From 5a37f96379fef1bb1f47e494f6977857ab2675b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Porras=20Campo?= Date: Wed, 27 May 2026 11:51:59 +0200 Subject: [PATCH] feat: add a TagExtension to replicate Xtend active annotation Tag --- .../xtend-to-java/rules/09-misc-syntax.md | 40 +++++++++++++ .../xtend-to-java/workflow/known-pitfalls.md | 1 + .../com/avaloq/tools/ddk/xtext/test/Tag.xtend | 3 + .../tools/ddk/xtext/test/TagExtension.java | 60 +++++++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/TagExtension.java diff --git a/.agents/skills/xtend-to-java/rules/09-misc-syntax.md b/.agents/skills/xtend-to-java/rules/09-misc-syntax.md index 4c5c62652d..1abebd4453 100644 --- a/.agents/skills/xtend-to-java/rules/09-misc-syntax.md +++ b/.agents/skills/xtend-to-java/rules/09-misc-syntax.md @@ -63,6 +63,46 @@ Write the accessor methods explicitly with the specified visibility. Generates a constructor taking all final fields as parameters. Write it manually. +### `@Tag` + +Assigns sequential integers (starting at `TagCompilationParticipant.COUNTER_BASE = 10000`) to test +marker fields at Xtend compile time via `TagCompilationParticipant`. + +Standard Java APT (`AbstractProcessor`) **cannot** replicate this — it can only generate new source +files, not modify initializers of existing fields. Use `TagExtension` instead: a JUnit 5 +`BeforeEachCallback` in `com.avaloq.tools.ddk.xtext.test.core` that performs the same sequential +assignment at runtime via reflection. + +**Migration steps:** + +1. Declare each `@Tag` field as `private int` — **no `final`**, **no explicit initializer**: + ```java + @Tag + private int MY_TAG; + ``` +2. Add `TagExtension.class` to `@ExtendWith` (alongside any existing extensions): + ```java + @ExtendWith({InjectionExtension.class, TagExtension.class}) + ``` +3. Add the import: + ```java + import com.avaloq.tools.ddk.xtext.test.TagExtension; + ``` + +**Why not `final`?** `TagExtension` uses `Field.setInt()` to write the value at runtime. +`Field.setInt()` on a `final` field throws `IllegalAccessException` even after `setAccessible(true)` +on Java 9+. Formatters and IDE save-actions routinely add `final` to `int` fields — always remove it +for `@Tag` fields. + +**Why not explicit initializers?** The extension assigns values in declaration order starting from +`COUNTER_BASE`. Hardcoded values are redundant and will silently drift out of sync when fields are +reordered or inserted. + +**Field ordering matters.** `TagExtension` iterates `getDeclaredFields()` in source-declaration order +(guaranteed by HotSpot; JVM spec does not mandate it, but all production JVMs preserve it). +Inserting a new `@Tag` field in the middle shifts subsequent values — exactly as the Xtend active +annotation would have done. + ## 9.7 Dispatch methods Xtend's multi-dispatch (`def dispatch ...`) has no Java equivalent. Convert to: individual `protected` methods prefixed with `_` plus a single dispatcher method that does the type-test routing. diff --git a/.agents/skills/xtend-to-java/workflow/known-pitfalls.md b/.agents/skills/xtend-to-java/workflow/known-pitfalls.md index 583c1e6867..b4088314ca 100644 --- a/.agents/skills/xtend-to-java/workflow/known-pitfalls.md +++ b/.agents/skills/xtend-to-java/workflow/known-pitfalls.md @@ -16,6 +16,7 @@ Consolidated table of common mistakes and their fixes. Review before and after e | **Missing `@throws` tags** | When Java migration adds `throws` and method already has Javadoc, Checkstyle requires `@throws`. Add it; don't create Javadoc just for the tag. | | **Duplicate string literals** | Checkstyle flags strings appearing 2+ times. In tests, extract to constants. In generators, use `CHECKSTYLE:CONSTANTS-OFF/ON`. | | **`@Data` / `@Accessors`** | These generate code at compile time. The `xtend-gen/` output shows exactly what — copy equals/hashCode/toString/getters from there. | +| **`@Tag` fields must not be `final`** | `TagExtension` assigns tag values via `Field.setInt()` at runtime. `Field.setInt()` on a `final` field fails on Java 9+ even after `setAccessible(true)`. IDE formatters and save-actions silently add `final` to `int` fields — always strip it from `@Tag` fields. See [`rules/09-misc-syntax.md`](../rules/09-misc-syntax.md) §9.6. | | **`BasicEList` in generic code** | Needs explicit type parameter — `new BasicEList()`. | | **StringBuilder in `xtend-gen/`** | If `xtend-gen/` has `StringConcatenation` but Xtend has a template, that's the signal to use text block or `.formatted()` (tier 1–3) or `StringBuilder` (tier 4). | | **Non-parameterized logging** | Xtend files often have `"msg" + x` in log calls. Fix to `{}` placeholders. | diff --git a/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/Tag.xtend b/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/Tag.xtend index ccdbafe5fc..892e50810b 100644 --- a/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/Tag.xtend +++ b/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/Tag.xtend @@ -10,6 +10,8 @@ *******************************************************************************/ package com.avaloq.tools.ddk.xtext.test +import java.lang.annotation.Retention +import java.lang.annotation.RetentionPolicy import org.eclipse.xtend.lib.macro.Active /** @@ -18,6 +20,7 @@ import org.eclipse.xtend.lib.macro.Active * Usage example: @Tag int MEM_DOC */ @Active(typeof(TagCompilationParticipant)) +@Retention(RetentionPolicy.RUNTIME) annotation Tag { } diff --git a/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/TagExtension.java b/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/TagExtension.java new file mode 100644 index 0000000000..096e907fd5 --- /dev/null +++ b/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/TagExtension.java @@ -0,0 +1,60 @@ +/******************************************************************************* + * Copyright (c) 2016 Avaloq Group AG and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Avaloq Group AG - initial API and implementation + *******************************************************************************/ +package com.avaloq.tools.ddk.xtext.test; + +import java.lang.reflect.Field; + +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +/** + * JUnit Jupiter extension that replicates the compile-time behaviour of the {@link Tag} active annotation in plain Java. + *

+ * When Xtend processes a class containing {@link Tag}-annotated fields, {@link TagCompilationParticipant} assigns + * sequential integer values starting from {@link TagCompilationParticipant#COUNTER_BASE} to those fields at compile + * time. This extension performs the equivalent assignment at runtime, immediately before each test method, so that + * plain Java test classes can use {@link Tag} without the Xtend compiler. + *

+ *

+ * Fields are processed in their declaration order. The counter is reset to {@link TagCompilationParticipant#COUNTER_BASE} + * for each test instance, matching the per-class scope of the Xtend compile-time transformation. + *

+ *

+ * Usage: add {@code @ExtendWith(TagExtension.class)} to the test class. The annotated fields must not be + * {@code final}. + *

+ */ +public class TagExtension implements BeforeEachCallback { + + /** + * Assigns sequential integer values to all {@link Tag}-annotated fields declared on the test class. + *

+ * Only fields declared directly on the test class are processed, not inherited ones. This matches + * the per-class scope of {@link TagCompilationParticipant}. + *

+ * + * @param context + * the current extension context, must not be {@code null} + * @throws IllegalAccessException + * if a {@link Tag}-annotated field cannot be written via reflection + */ + @Override + public void beforeEach(final ExtensionContext context) throws IllegalAccessException { + Object testInstance = context.getRequiredTestInstance(); + int counter = TagCompilationParticipant.COUNTER_BASE; + for (Field field : testInstance.getClass().getDeclaredFields()) { + if (field.isAnnotationPresent(Tag.class)) { + field.setAccessible(true); + field.setInt(testInstance, counter++); + } + } + } +}