Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/skills/xtend-to-java/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,8 @@ Use this table for quick mechanical transforms. Full details in the rule files.
| `#{a, b, c}` (immutable set) | `Set.of(a, b, c)` |
| `newArrayList(...)` | `new ArrayList<>(List.of(...))` |
| `newHashMap(...)` | `new HashMap<>(Map.of(...))` |
| `list += element` | `list.add(element)` |
| `list += otherList` | `list.addAll(otherList)` |
| `list += element` | `list.add(element)` for an ordinary collection; an inferrer's `EList` may bind to null-skipping `JvmTypesBuilder.operator_add` — see [`rules/10`](rules/10-jvm-model-inferrer.md) §10.4 |
| `list += otherList` | `list.addAll(otherList)` for an ordinary collection; preserve the same inferrer null-skip exception |
| `list -= element` | `list.remove(element)` |

### Extension methods
Expand Down
6 changes: 3 additions & 3 deletions .agents/skills/xtend-to-java/examples/00-basic-generator.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Example: basic generator conversion

A small generator with `@Inject extension`, `override`, null-safe navigation, `typeof`, template expression with `«FOR»` and `«IF»`, and a `static extension` import. Touches rules 01, 02, 03, 04, 06, 08, and 09.
A small generator with `@Inject extension`, `override`, null-safe navigation, `typeof`, template expression with `«FOR»` and `«IF»`, and a `static extension` import. Touches rules 01, 02, 03, 04, 06, 08, and 09. Assume the illustrative `BaseGenerator` declares `doGenerate(Resource)`.

## Xtend input

Expand All @@ -12,7 +12,7 @@ import org.eclipse.emf.ecore.resource.Resource
import static org.eclipse.xtext.xbase.lib.IteratorExtensions.*
import static extension com.example.NamingExtensions.*

class MyGenerator {
class MyGenerator extends BaseGenerator {
@Inject extension MyHelper helper

override void doGenerate(Resource resource) {
Expand Down Expand Up @@ -50,7 +50,7 @@ import com.google.common.collect.Iterables;
import com.google.inject.Inject;

@SuppressWarnings("nls")
public class MyGenerator {
public class MyGenerator extends BaseGenerator {

@Inject private MyHelper helper;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Use Guava **only** where it is genuinely more concise (marked with ★).
| `IterableExtensions.filter(iter, fn)` | `iter.stream().filter(fn).toList()` |
| `IterableExtensions.filter(iter, Type.class)` | ★ `Iterables.filter(iter, Type.class)` (Guava — type-safe, no cast needed) |
| `IterableExtensions.toList(iter)` | `StreamSupport.stream(iter.spliterator(), false).toList()` or loop |
| `IterableExtensions.toSet(iter)` | `StreamSupport.stream(iter.spliterator(), false).collect(Collectors.toSet())` |
| `IterableExtensions.toSet(iter)` | `StreamSupport.stream(iter.spliterator(), false).collect(Collectors.toCollection(LinkedHashSet::new))` — preserves first-encounter order; if aliasing matters, note that Xtend returns `iter` unchanged when it is already a `Set` |
| `IterableExtensions.head(iter)` | ★ `Iterables.getFirst(iter, null)` (Guava — null-safe one-liner) |
| `IterableExtensions.join(iter, sep)` | `String.join(sep, iter)` (if `Iterable<String>`) or `StreamSupport.stream(...).map(Object::toString).collect(Collectors.joining(sep))` |
| `IterableExtensions.join(iter, sep, fn)` | `iter.stream().map(fn).collect(Collectors.joining(sep))` |
Expand Down
20 changes: 13 additions & 7 deletions .agents/skills/xtend-to-java/rules/03-methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,16 @@ public SomeType foo() {

## 3.5 Checked exceptions

Xtend doesn't enforce checked exceptions. Java does. When the body calls APIs that throw checked exceptions:

- Add `throws ...` to the method signature, **or**
- Wrap in `try`/`catch`.
- Common cases: `CoreException` from Eclipse APIs, `IOException` from I/O.

Catch specific exceptions — never generic `Exception`. See the quality checklist for IllegalCatch.
Xtend doesn't enforce checked exceptions. Java does. Treat that mismatch as an API-design decision rather
than copying the compiler's workaround:

- keep explicit catches specific, and catch only the narrow checked types Java requires;
- declare the exact checked exception when the method is private/package-local or its inherited API permits it;
- when an override or compatibility-sensitive public API cannot declare the exception, stop and obtain explicit
review for the boundary strategy (for example, an established project-specific unchecked exception), preserving
the original exception as the cause.

Common cases are `CoreException` from Eclipse APIs and `IOException` from I/O. Never introduce an arbitrary
wrapper merely to make the migration compile. See [`rules/05-control-flow.md`](./05-control-flow.md) §5.5 and
the quality checklist. Never copy `xtend-gen`'s broad `catch (Throwable)` scaffold unless the invoked API itself
declares `Throwable` and no narrower catch can compile.
24 changes: 24 additions & 0 deletions .agents/skills/xtend-to-java/rules/05-control-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,27 @@ final String label = x != null ? x.getName() : "<none>";
```

For multi-line bodies, factor to a helper method or write `if`/`else` with an assignment in each branch.

## 5.5 Exception handling — preserve behaviour without broad catches

Xtend hides checked exceptions. Its generated Java may contain broad exception-handling scaffolding, but that is
**not a migration template**. Write an explicit Java exception contract using the narrowest types that compile:

- Keep every explicit Xtend `catch (SpecificException e)` as the same specific Java catch. Non-matching
exceptions and errors already propagate unchanged without being caught.
- If an uncaught checked exception can be declared without violating an override or compatibility-sensitive
public API, add that exact exception to the `throws` clause and let it propagate normally.
- If the method cannot declare it, do not choose a workaround mechanically. Obtain explicit review for the
boundary strategy. When the project has an established unchecked counterpart (for example,
`UncheckedIOException` or Xtext's `RuntimeIOException`), catch only the precise checked type at the smallest
scope and preserve it as the cause.
- **Do not catch `Throwable`, `Exception`, or `RuntimeException` merely to copy `xtend-gen`.** A broad catch is
permitted only when the invoked API itself declares that exact broad type, no narrower compiler-visible
catch can compile, and preserving the public signature is required. Keep that exceptional catch as small
as possible and justify the narrow `@SuppressWarnings("checkstyle:IllegalCatch")` at the site.
- Adding a checked `throws` clause to a compatibility-sensitive public API is an API change and requires explicit
review.
- **Do not invent an arbitrary wrapper.** `new RuntimeException(e)` / `new IllegalStateException(e)` changes the
exception contract and is not a neutral migration. Use an established domain-specific counterpart only after
explicit review, and always preserve the caught exception as the cause. (A legitimate
`throw new IllegalStateException("message")` for a genuinely bad state, with no caught cause, is unrelated.)
4 changes: 4 additions & 0 deletions .agents/skills/xtend-to-java/rules/08-operator-overloads.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,9 @@ Same in Java.

- `list += element` → `list.add(element)`
- `list += otherList` → `list.addAll(otherList)`
- ⚠ **Exception**: when the receiver is an `EList` and `JvmTypesBuilder` is an in-scope extension
(every JVM model inferrer), `+=` binds to `JvmTypesBuilder.operator_add`; **both overloads skip nulls**
(and also no-op on a null list). A plain `add`/`addAll` is then NOT faithful — see
[`rules/10-jvm-model-inferrer.md`](./10-jvm-model-inferrer.md) §10.4 before translating any `+=` in an inferrer.
- `list -= element` → `list.remove(element)`
- `map[key]` (Xtend bracket access) → `map.get(key)`
22 changes: 22 additions & 0 deletions .agents/skills/xtend-to-java/rules/09-misc-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,25 @@ Rules:
- **Copy Javadoc from the Xtend source verbatim.** Never generate, guess, or infer Javadoc that was not in the original. Invented comments are misleading.
- **`@throws` tags**: Only add when (1) the method already has Javadoc AND (2) the migrated signature declares a `throws` clause. Do not add Javadoc just to host a `@throws` tag.
- Do **not** add `@SuppressWarnings("all")` — the Xtend compiler injects this into `xtend-gen/`; human-converted Java shouldn't have it.

## 9.11 Charset — verify the contract before deviating from `xtend-gen`

When the Xtend source constructs a reader/writer with **no charset** (`new InputStreamReader(stream)`,
`new String(bytes)`, `.getBytes()`), `xtend-gen` faithfully reproduces the **platform-default** charset.
Do not mechanically reproduce it. PMD `RelianceOnDefaultCharset` flags the *implicit* default — an explicit
`Charset.defaultCharset()` would pass the gate, but it usually keeps unintended platform dependence. Determine
the **data contract** first:

- if the API or format supplies an encoding, honour it — e.g. pass `file.getCharset()` when reading an Eclipse
`IFile` (the `InputStreamReader(InputStream, String)` overload accepts that value);
- for repository-owned text governed by `ddk-parent/pom.xml`'s UTF-8 project encoding, use
`java.nio.charset.StandardCharsets.UTF_8`:
`new InputStreamReader(stream, StandardCharsets.UTF_8)`;
- for opaque external data with no documented encoding, do **not** guess UTF-8 from the Java source-encoding
setting. Establish the contract. If platform-default encoding is genuinely part of that contract, use
`Charset.defaultCharset()` explicitly and record why preserving it is intentional.

This is a sanctioned divergence from `xtend-gen` only when the chosen charset follows a verified contract;
call it out and test it. Do not use a lint warning as blanket permission for an unrelated behaviour change.
Two legacy `// NOPMD` suppressions of this rule exist in hand-written code (`CheckPreferencesHelper`,
`XtextGMFResourceUtil`); they are grandfathered, not a precedent for migrations.
82 changes: 70 additions & 12 deletions .agents/skills/xtend-to-java/rules/10-jvm-model-inferrer.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ is the canonical worked example — read it in full before migrating another inf
| `@Inject extension JvmTypesBuilder` | `@Inject private JvmTypesBuilder jvmTypesBuilder;` — every extension call becomes explicit (`jvmTypesBuilder.toClass(...)`) |
| `def dispatch infer(X x, IJvmDeclaredTypeAcceptor acceptor, boolean preIndexingPhase)` | `_infer(final X x, final IJvmDeclaredTypeAcceptor acceptor, final boolean isPreIndexingPhase)` + the dispatcher pattern ([`rules/09-misc-syntax.md`](./09-misc-syntax.md) §9.7) |
| `x.toClass(name)` | `jvmTypesBuilder.toClass(x, name)` |
| `acceptor.accept(cls, [ ... ])` | `acceptor.<JvmGenericType>accept(cls, initializer)` where `initializer` is a `Procedure1<JvmGenericType>` (see `FormatJvmModelInferrer.java:182-192`) |
| `members += x` / `superTypes += x` / `annotations += x` | `it.getMembers().add(x)` / `it.getSuperTypes().add(x)` / `it.getAnnotations().add(x)` |
| `x.toMethod(name, type) [ ... ]` | `jvmTypesBuilder.toMethod(x, name, type, initializer)` with a `Procedure1<JvmOperation>` (`:223-235`) |
| `x.toField(name, type) [ ... ]` / `x.toParameter(name, type)` | `jvmTypesBuilder.toField(x, name, type, initializer)` / `jvmTypesBuilder.toParameter(x, name, type)` (`:245`) |
| `typeRef(T)` / `typeRef(name)` | `_typeReferenceBuilder.typeRef(...)` — the protected field inherited from `AbstractModelInferrer` (`:202-204`); for lookups needing a context object use `typeReferences.getTypeForName(name, context)` (`:235`) |
| `documentation = '''...'''` | `jvmTypesBuilder.setDocumentation(it, "...".formatted(...))` (`:198`) |
| `static = true` / `visibility = PROTECTED` / `abstract = true` | `it.setStatic(true)` / `method.setVisibility(JvmVisibility.PROTECTED)` / `it.setAbstract(true)` (`:207,224`) |
| `acceptor.accept(cls, [ ... ])` | `acceptor.<JvmGenericType>accept(cls, initializer)` where `initializer` is a `Procedure1<JvmGenericType>` (see `FormatJvmModelInferrer._infer`) |
| `members += x` / `superTypes += x` / `annotations += x` | `it.getMembers().add(x)` / … — **only when `x` is provably non-null**; both `JvmTypesBuilder.operator_add` overloads skip nulls (single element and collection), so see §10.4 before translating any `+=` |
| `x.toMethod(name, type) [ ... ]` | `jvmTypesBuilder.toMethod(x, name, type, initializer)` with a `Procedure1<JvmOperation>` (see `FormatJvmModelInferrer.inferGetGrammarAccess`) |
| `x.toField(name, type) [ ... ]` / `x.toParameter(name, type)` | `jvmTypesBuilder.toField(x, name, type, initializer)` / `jvmTypesBuilder.toParameter(x, name, type)` |
| `typeRef(T)` / `typeRef(name)` | `_typeReferenceBuilder.typeRef(...)` — the protected field inherited from `AbstractModelInferrer`; for lookups needing a context object use `typeReferences.getTypeForName(name, context)` |
| `documentation = '''...'''` | `jvmTypesBuilder.setDocumentation(it, "...".formatted(...))` (see `FormatJvmModelInferrer.inferClass`) |
| `static = true` / `visibility = PROTECTED` / `abstract = true` | `it.setStatic(true)` / `method.setVisibility(JvmVisibility.PROTECTED)` / `it.setAbstract(true)` |
| `initializer = expr` (on a field) | set inside the field's initializer `Procedure1` via the corresponding setter/`jvmTypesBuilder` call — read `xtend-gen/` for the exact form |

## 10.2 Method bodies
Expand All @@ -33,7 +33,7 @@ Xtend assigns bodies two ways; both become `jvmTypesBuilder.setBody(method, ...)
};
jvmTypesBuilder.setBody(method, body);
```
(`FormatJvmModelInferrer.java:229-232`)
(see `FormatJvmModelInferrer.inferGetGrammarAccess`)
- `body = '''template'''` (template form) → the Xtend compiler emits the
`StringConcatenationClient` overload of `setBody`. Either keep that overload (check
`xtend-gen/`) or convert to the `Procedure1<ITreeAppendable>` form with the template text
Expand All @@ -44,14 +44,72 @@ Xtend assigns bodies two ways; both become `jvmTypesBuilder.setBody(method, ...)

- The inference closures are long by design; bracket the class with
`// CHECKSTYLE:CHECK-OFF LambdaBodyLength the model-inference closures mirror the Xtext JvmTypesBuilder API and are kept whole`
(see `FormatJvmModelInferrer.java:114`).
(see the class-level suppression in `FormatJvmModelInferrer`).
- Emitted Java source fragments are repeated literals — `// CHECKSTYLE:CONSTANTS-OFF` applies
(same file, `:113`).
(see the class-level suppression in `FormatJvmModelInferrer`).
- `members += list.map(...).flatten.filterNull` chains: see
[`references/xtend-library-replacements.md`](../references/xtend-library-replacements.md)
for `flatten`/`filterNull` stream equivalents; the result feeds `getMembers().addAll(...)`.
for `flatten`/`filterNull` stream equivalents; the result feeds the add — but read §10.4 first
for the null-skip requirement, which is the most dangerous inferrer-migration trap.

## 10.4 Verification
## 10.4 ⚠ `operator_add` (`+=`) SKIPS nulls — plain `add`/`addAll` does NOT

**This is the highest-risk inferrer defect: it passes every static gate (PMD/Checkstyle/SpotBugs) and
every test that does not happen to feed a null — and then fails at runtime the moment one does.**
The failure is fast and loud, not silent: JVM model containment lists (`getMembers()` etc.) are EMF
`EObjectEList`s with `canContainNull() == false`, so a bare `.add(null)`/`.addAll(...)` throws
`IllegalArgumentException("The 'no null' constraint is violated")` **at the add call**. Xtend's `+=`
never produces that null add in the first place — that is the behaviour a faithful migration must keep.

`JvmTypesBuilder` provides **two** `operator_add` overloads, and **both skip nulls** (they also no-op on
a null list): `operator_add(EList, T)` is `if (list != null && element != null) list.add(element)`,
and the `Iterable` overload delegates to it per element. So the trap covers the single-element form too:
Xtend `members += toField(...)` silently skips a null factory result, while the doc-obvious
`it.getMembers().add(toField(...))` throws on it.

And the factories DO return null. In Xtext 2.43.0, named builders such as `toClass`, `toInterface`,
`toAnnotationType`, `toEnumerationType`, `toField`, `toMethod`, `toParameter`, and
`toEnumerationLiteral` guard their **source element and name**; `toConstructor` guards its source element;
and `toGetter` / `toSetter` guard the source element plus their accessor/field names. A null **type**
argument does not by itself trigger a null return in the field/method/parameter/accessor builders. Check the
exact overload used rather than treating this list as a substitute for source inspection. Any local helper
with a `return null` fall-through (a `switch`/`if` that doesn't match) is a trigger too.

So the faithful Java of any `+=` whose right-hand side can be null is a guarded add:

```java
// WRONG — throws IllegalArgumentException("The 'no null' constraint is violated") at the add
// the first time createConstant returns null (value-less constant):
for (final Constant c : constants) {
it.getMembers().add(createConstant(format, c));
}

// RIGHT — reproduce operator_add's null-skip (either form):
for (final Constant c : constants) {
final JvmMember member = createConstant(format, c);
if (member != null) {
it.getMembers().add(member);
}
}
// or, matching the Xtend chain shape with the JDK stream equivalents
// (per references/xtend-library-replacements.md — no xbase.lib in migrated Java):
it.getMembers().addAll(constants.stream()
.map(c -> createConstant(format, c))
.filter(Objects::nonNull)
.toList());
```

**Checklist for every `+=` site in a migrated inferrer — single element or collection:** can the producer
return null (nullable source element or name, or a `return null` branch)? If yes, there MUST be a null
guard / `Objects::nonNull` filter. A bare `add`/`addAll` over a null-capable producer is a faithfulness
regression.

> Real shipped example: `FormatJvmModelInferrer.inferConstants` used a bare add for
> `members += allConstants.map[createConstant]`, although `createConstant` returns null for a value-less
> constant. The guard now in that method and its regression test are the canonical fix; the defect escaped
> the gates because no earlier test supplied the null-producing input.

## 10.5 Verification

An inferrer is a generator: its OUTPUT (the inferred JVM model, and through it the generated
Java) is the ground truth. Byte-verify emitted body/documentation strings against `xtend-gen/`
Expand Down
Loading