Skip to content

ShortenFullyQualifiedTypeReferences drops the qualifier and import for nested types on Kotlin #8237

Description

@vlsi

What version of OpenRewrite are you using?

rewrite 8.86 / rewrite-gradle-plugin 7.35, reproduced against main.

How are you running OpenRewrite?

ShortenFullyQualifiedTypeReferences (via a CodeCleanup composite) applied to a Kotlin source in a mixed Java/Kotlin module.

What is the smallest, simplest way to reproduce the problem?

import org.apiguardian.api.API

@API(status = API.Status.EXPERIMENTAL, since = "5.6")
class Foo

ShortenFullyQualifiedTypeReferences rewrites the nested enum access API.Status.EXPERIMENTAL to Status.EXPERIMENTAL without adding an import for Status:

import org.apiguardian.api.API

@API(status = Status.EXPERIMENTAL, since = "5.6")
class Foo

The result no longer compiles (unresolved reference: Status).

A test that reproduces it is in the accompanying PR.

What did you expect to see?

The reference is left unchanged — it already uses the simple, imported outer name API. This is what the recipe does on the equivalent Java source.

What happened instead?

The qualifier API. is dropped and no import is added, producing uncompilable Kotlin.

Root cause

The recipe guard is correct. visitFieldAccess only shortens a reference whose type is top-level: type instanceof JavaType.Class && ((JavaType.Class) type).getOwningClass() == null. For the nested enum org.apiguardian.api.API.Status the owning class is API, so a correctly attributed reference is skipped, as it is on Java.

The bug is in Kotlin type attribution: the nested reference API.Status was given the outer type org.apiguardian.api.API instead of the nested type org.apiguardian.api.API$Status. That outer type is genuinely top-level, so its owningClass is null, the guard passes, and the recipe shortens API.Status to Status. The follow-up maybeAddImport call then receives the outer name org.apiguardian.api.API and does nothing, because API is already imported, so no new import appears. (The earlier "maybeAddImport is a no-op for nested types" reading was the same wrong type surfacing again, not a second gap.)

Concretely, PsiElementAssociations.matchClassId0 matched the reference's source text only against the fully-qualified class name (org.apiguardian.api.API.Status). The import-shortened form API.Status never matched, so the lookup walked up the outerClassId chain and returned the outer class id. An aliased import (import org.apiguardian.api.API as GuardApi, then GuardApi.Status) and an imported nested type (import foo.bar.A.B, then B.A.C) hit the same path, because their source root matches neither the fully-qualified nor the package-relative name.

The fix resolves each of these forms to its own class id: it matches the package-relative name, falls back to the leaf name plus nesting depth for the alias form, and to a trailing run of the nested names for the imported-nested form. The nested reference then keeps its own class id, owningClass is API, and the guard correctly skips it.

Are you interested in contributing a fix to OpenRewrite?

Fixed in #8238: matchClassId0 resolves the package-relative, aliased, and imported-nested forms to their own class id, with type-mapping and recipe tests for the imported-outer, multi-level, aliased, same-simple-name, and imported-nested cases.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

Status
In Progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions