From de98d83982c63b44f892ed8205cc6c0db0f38886 Mon Sep 17 00:00:00 2001 From: Cause Cheng Date: Mon, 16 Mar 2026 17:11:05 -0400 Subject: [PATCH 1/2] feat: support type parameter type bounds #7 --- .../domain/type/TypeVariableInfo.java | 10 +++- .../sharedtype/it/java8/TypeBoundsIssue7.java | 31 ++++++++++ .../processor/parser/ClassTypeDefParser.java | 14 ++++- .../writer/converter/GoStructConverter.java | 17 +++++- .../writer/converter/RustStructConverter.java | 11 +++- .../TypescriptInterfaceConverter.java | 11 +++- .../parser/ClassTypeDefParserTest.java | 56 +++++++++++++++++++ .../converter/GoStructConverterTest.java | 29 +++++++++- .../converter/RustStructConverterTest.java | 26 +++++++++ ...riptInterfaceConverterIntegrationTest.java | 29 ++++++++++ 10 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 it/java8/src/main/java/online/sharedtype/it/java8/TypeBoundsIssue7.java diff --git a/internal/src/main/java/online/sharedtype/processor/domain/type/TypeVariableInfo.java b/internal/src/main/java/online/sharedtype/processor/domain/type/TypeVariableInfo.java index bd66e05d..d8bd0484 100644 --- a/internal/src/main/java/online/sharedtype/processor/domain/type/TypeVariableInfo.java +++ b/internal/src/main/java/online/sharedtype/processor/domain/type/TypeVariableInfo.java @@ -3,8 +3,11 @@ import lombok.Builder; import lombok.EqualsAndHashCode; +import java.util.Collections; +import java.util.List; import java.util.Map; + /** * Represents a generic type variable. *
@@ -21,7 +24,8 @@ public final class TypeVariableInfo extends ReferableTypeInfo { private final String contextTypeQualifiedName; // TODO: reference to TypeDef to avoid string private final String name; private String qualifiedName; - // TODO: support generic bounds + @Builder.Default + private final List bounds = Collections.emptyList(); public static String concatQualifiedName(String contextTypeQualifiedName, String name) { return contextTypeQualifiedName + "@" + name; @@ -35,6 +39,10 @@ public String name() { return name; } + public List bounds() { + return bounds; + } + public String qualifiedName() { if (qualifiedName == null) { qualifiedName = concatQualifiedName(contextTypeQualifiedName, name); diff --git a/it/java8/src/main/java/online/sharedtype/it/java8/TypeBoundsIssue7.java b/it/java8/src/main/java/online/sharedtype/it/java8/TypeBoundsIssue7.java new file mode 100644 index 00000000..1a040433 --- /dev/null +++ b/it/java8/src/main/java/online/sharedtype/it/java8/TypeBoundsIssue7.java @@ -0,0 +1,31 @@ +package online.sharedtype.it.java8; + +import online.sharedtype.SharedType; + +@SharedType +public class TypeBoundsIssue7 { + @SharedType + public interface Shape { + double area(); + } + + @SharedType + public static class Circle implements Shape { + public double radius; + + @Override + public double area() { + return 3.14 * radius * radius; + } + } + + @SharedType + public static class ContainerBounds { + public T shape; + } + + @SharedType + public static class MultiBoundContainer { + public T shape; + } +} diff --git a/processor/src/main/java/online/sharedtype/processor/parser/ClassTypeDefParser.java b/processor/src/main/java/online/sharedtype/processor/parser/ClassTypeDefParser.java index adb51523..9e6a6005 100644 --- a/processor/src/main/java/online/sharedtype/processor/parser/ClassTypeDefParser.java +++ b/processor/src/main/java/online/sharedtype/processor/parser/ClassTypeDefParser.java @@ -97,9 +97,21 @@ private List parseTypeVariables(TypeElement typeElement) { TypeVariableInfo.builder() .contextTypeQualifiedName(typeElement.getQualifiedName().toString()) .name(typeParameterElement.getSimpleName().toString()) + .bounds(parseBounds(typeParameterElement, typeElement)) .build() ) - .collect(Collectors.toList()); // TODO: type bounds + .collect(Collectors.toList()); + } + + private List parseBounds(TypeParameterElement typeParameterElement, TypeElement typeElement) { + return typeParameterElement.getBounds().stream() + .filter(b -> !"java.lang.Object".equals(b.toString())) + .filter(b -> { + Element e = ctx.getProcessingEnv().getTypeUtils().asElement(b); + return e == null || !ctx.isIgnored(e); + }) + .map(b -> typeInfoParser.parse(b, typeElement)) + .collect(Collectors.toList()); } private List parseSupertypes(TypeElement typeElement) { diff --git a/processor/src/main/java/online/sharedtype/processor/writer/converter/GoStructConverter.java b/processor/src/main/java/online/sharedtype/processor/writer/converter/GoStructConverter.java index 9638f889..21ad4c15 100644 --- a/processor/src/main/java/online/sharedtype/processor/writer/converter/GoStructConverter.java +++ b/processor/src/main/java/online/sharedtype/processor/writer/converter/GoStructConverter.java @@ -29,7 +29,20 @@ public Tuple convert(TypeDef typeDef) { ClassDef classDef = (ClassDef) typeDef; StructExpr value = new StructExpr( classDef.simpleName(), - classDef.typeVariables().stream().map(typeInfo -> typeExpressionConverter.toTypeExpr(typeInfo, typeDef)).collect(Collectors.toList()), + classDef.typeVariables().stream().map(typeVar -> { + String name = typeVar.name(); + if (typeVar.bounds().isEmpty()) { + return name + " any"; + } + String bounds = typeVar.bounds().stream() + .map(b -> typeExpressionConverter.toTypeExpr(b, typeDef)) + .collect(Collectors.joining("; ")); + + if (typeVar.bounds().size() > 1) { + return name + " interface{ " + bounds + " }"; + } + return name + " " + bounds; + }).collect(Collectors.toList()), classDef.directSupertypes().stream().map(typeInfo1 -> typeExpressionConverter.toTypeExpr(typeInfo1, typeDef)).collect(Collectors.toList()), gatherProperties(classDef) ); @@ -64,7 +77,7 @@ String typeParametersExpr() { if (typeParameters.isEmpty()) { return null; } - return String.format("[%s any]", String.join(", ", typeParameters)); + return String.format("[%s]", String.join(", ", typeParameters)); } } diff --git a/processor/src/main/java/online/sharedtype/processor/writer/converter/RustStructConverter.java b/processor/src/main/java/online/sharedtype/processor/writer/converter/RustStructConverter.java index 858c6e33..4b9fb07f 100644 --- a/processor/src/main/java/online/sharedtype/processor/writer/converter/RustStructConverter.java +++ b/processor/src/main/java/online/sharedtype/processor/writer/converter/RustStructConverter.java @@ -47,7 +47,16 @@ public Tuple convert(TypeDef typeDef) { ClassDef classDef = (ClassDef) typeDef; StructExpr value = new StructExpr( classDef.simpleName(), - classDef.typeVariables().stream().map(typeInfo -> typeExpressionConverter.toTypeExpr(typeInfo, typeDef)).collect(Collectors.toList()), + classDef.typeVariables().stream().map(typeVar -> { + String name = typeVar.name(); + if (typeVar.bounds().isEmpty()) { + return name; + } + String bounds = typeVar.bounds().stream() + .map(b -> typeExpressionConverter.toTypeExpr(b, typeDef)) + .collect(Collectors.joining(" + ")); + return name + ": " + bounds; + }).collect(Collectors.toList()), gatherProperties(classDef), rustMacroTraitsGenerator.generate(classDef) ); diff --git a/processor/src/main/java/online/sharedtype/processor/writer/converter/TypescriptInterfaceConverter.java b/processor/src/main/java/online/sharedtype/processor/writer/converter/TypescriptInterfaceConverter.java index c8885054..9e944368 100644 --- a/processor/src/main/java/online/sharedtype/processor/writer/converter/TypescriptInterfaceConverter.java +++ b/processor/src/main/java/online/sharedtype/processor/writer/converter/TypescriptInterfaceConverter.java @@ -38,7 +38,16 @@ public Tuple convert(TypeDef typeDef) { Config config = ctx.getTypeStore().getConfig(typeDef); InterfaceExpr value = new InterfaceExpr( classDef.simpleName(), - classDef.typeVariables().stream().map(typeInfo -> typeExpressionConverter.toTypeExpr(typeInfo, typeDef)).collect(Collectors.toList()), + classDef.typeVariables().stream().map(typeVar -> { + String name = typeVar.name(); + if (typeVar.bounds().isEmpty()) { + return name; + } + String bounds = typeVar.bounds().stream() + .map(b -> typeExpressionConverter.toTypeExpr(b, typeDef)) + .collect(Collectors.joining(" & ")); + return name + " extends " + bounds; + }).collect(Collectors.toList()), classDef.directSupertypes().stream().map(typeInfo1 -> typeExpressionConverter.toTypeExpr(typeInfo1, typeDef)).collect(Collectors.toList()), classDef.components().stream().map(field -> toPropertyExpr(field, typeDef, config)).collect(Collectors.toList()) ); diff --git a/processor/src/test/java/online/sharedtype/processor/parser/ClassTypeDefParserTest.java b/processor/src/test/java/online/sharedtype/processor/parser/ClassTypeDefParserTest.java index 52ea7a31..1423e634 100644 --- a/processor/src/test/java/online/sharedtype/processor/parser/ClassTypeDefParserTest.java +++ b/processor/src/test/java/online/sharedtype/processor/parser/ClassTypeDefParserTest.java @@ -156,4 +156,60 @@ void ignoreGlobalConfiguredField() { var classDef = parser.parse(typeElement).get(0); assertThat(classDef.components()).isEmpty(); } + + @Test + void parseTypeBounds() { + var boundTypeMock = ctxMocks.typeElement("com.example.Bound"); + var boundType = boundTypeMock.type(); + var typeParam = ctxMocks.typeParameter("T"); + + java.util.List bounds = new java.util.ArrayList<>(); + bounds.add(boundType); + org.mockito.Mockito.doReturn(bounds).when(typeParam.element()).getBounds(); + + var clazz = ctxMocks.typeElement("com.example.MyClass") + .withTypeParameters(typeParam.element()) + .element(); + + var parsedBoundType = ConcreteTypeInfo.builder().qualifiedName("com.example.Bound").build(); + when(typeInfoParser.parse(boundType, clazz)).thenReturn(parsedBoundType); + + var parsedSelfTypeInfo = ConcreteTypeInfo.builder().qualifiedName("com.example.MyClass").build(); + when(typeInfoParser.parse(clazz.asType(), clazz)).thenReturn(parsedSelfTypeInfo); + + var classDefs = parser.parse(clazz); + var classDef = (ClassDef)classDefs.get(0); + + assertThat(classDef.typeVariables()).hasSize(1); + var typeVar = classDef.typeVariables().get(0); + assertThat(typeVar.name()).isEqualTo("T"); + assertThat(typeVar.bounds()).containsExactly(parsedBoundType); + } + + @Test + void parseTypeBoundsWithObject() { + var objectTypeMock = ctxMocks.typeElement("java.lang.Object"); + var objectType = objectTypeMock.type(); + var typeParam = ctxMocks.typeParameter("T"); + + java.util.List bounds = new java.util.ArrayList<>(); + bounds.add(objectType); + org.mockito.Mockito.doReturn(bounds).when(typeParam.element()).getBounds(); + when(objectType.toString()).thenReturn("java.lang.Object"); + + var clazz = ctxMocks.typeElement("com.example.MyClass") + .withTypeParameters(typeParam.element()) + .element(); + + var parsedSelfTypeInfo = ConcreteTypeInfo.builder().qualifiedName("com.example.MyClass").build(); + when(typeInfoParser.parse(clazz.asType(), clazz)).thenReturn(parsedSelfTypeInfo); + + var classDefs = parser.parse(clazz); + var classDef = (ClassDef)classDefs.get(0); + + assertThat(classDef.typeVariables()).hasSize(1); + var typeVar = classDef.typeVariables().get(0); + assertThat(typeVar.name()).isEqualTo("T"); + assertThat(typeVar.bounds()).isEmpty(); + } } diff --git a/processor/src/test/java/online/sharedtype/processor/writer/converter/GoStructConverterTest.java b/processor/src/test/java/online/sharedtype/processor/writer/converter/GoStructConverterTest.java index f2e2bfe5..252daf0d 100644 --- a/processor/src/test/java/online/sharedtype/processor/writer/converter/GoStructConverterTest.java +++ b/processor/src/test/java/online/sharedtype/processor/writer/converter/GoStructConverterTest.java @@ -85,7 +85,7 @@ void convert() { assertThat(data).isNotNull(); var model = (GoStructConverter.StructExpr) data.b(); assertThat(model.name).isEqualTo("ClassA"); - assertThat(model.typeParameters).containsExactly("T"); + assertThat(model.typeParameters).containsExactly("T any"); assertThat(model.typeParametersExpr()).isEqualTo("[T any]"); assertThat(model.supertypes).containsExactly("SuperClassA[string]"); @@ -115,6 +115,33 @@ void convert() { assertThat(prop5.type).isEqualTo("map[string]int32"); } + @Test + void convertTypeWithBounds() { + ClassDef classDef = ClassDef.builder() + .simpleName("ClassA") + .qualifiedName("com.github.cuzfrog.ClassA") + .typeVariables(List.of( + TypeVariableInfo.builder() + .name("T") + .bounds(List.of( + ConcreteTypeInfo.builder() + .qualifiedName("com.github.cuzfrog.Shape") + .simpleName("Shape") + .build() + )) + .build() + )) + .components(List.of()) + .build(); + + var data = converter.convert(classDef); + var model = (GoStructConverter.StructExpr) data.b(); + + assertThat(model.name).isEqualTo("ClassA"); + assertThat(model.typeParameters).containsExactly("T Shape"); + assertThat(model.typeParametersExpr()).isEqualTo("[T Shape]"); + } + @Test void inlineTagsOverrideDefaultTags() { var propertyExpr = new GoStructConverter.PropertyExpr( diff --git a/processor/src/test/java/online/sharedtype/processor/writer/converter/RustStructConverterTest.java b/processor/src/test/java/online/sharedtype/processor/writer/converter/RustStructConverterTest.java index 7ed81994..dd7e2bff 100644 --- a/processor/src/test/java/online/sharedtype/processor/writer/converter/RustStructConverterTest.java +++ b/processor/src/test/java/online/sharedtype/processor/writer/converter/RustStructConverterTest.java @@ -219,4 +219,30 @@ void convertComplexType() { assertThat(prop4.optional).isFalse(); assertThat(prop4.typeExpr()).isEqualTo("String"); } + + @Test + void convertTypeWithBounds() { + ClassDef classDef = ClassDef.builder() + .simpleName("ClassA") + .qualifiedName("com.github.cuzfrog.ClassA") + .typeVariables(List.of( + TypeVariableInfo.builder() + .name("T") + .bounds(List.of( + ConcreteTypeInfo.builder() + .qualifiedName("com.github.cuzfrog.Shape") + .simpleName("Shape") + .build() + )) + .build() + )) + .components(List.of()) + .build(); + + var data = converter.convert(classDef); + var model = (RustStructConverter.StructExpr) data.b(); + + assertThat(model.name).isEqualTo("ClassA"); + assertThat(model.typeParameters).containsExactly("T: Shape"); + } } diff --git a/processor/src/test/java/online/sharedtype/processor/writer/converter/TypescriptInterfaceConverterIntegrationTest.java b/processor/src/test/java/online/sharedtype/processor/writer/converter/TypescriptInterfaceConverterIntegrationTest.java index e7401013..1bd72843 100644 --- a/processor/src/test/java/online/sharedtype/processor/writer/converter/TypescriptInterfaceConverterIntegrationTest.java +++ b/processor/src/test/java/online/sharedtype/processor/writer/converter/TypescriptInterfaceConverterIntegrationTest.java @@ -181,4 +181,33 @@ void optionalFieldUnionNullAndUndefined() { assertThat(prop1.unionUndefined).isTrue(); assertThat(prop1.readonly).isFalse(); } + + @Test + void writeInterfaceWithBounds() { + ClassDef classDef = ClassDef.builder() + .qualifiedName("com.github.cuzfrog.ClassA") + .simpleName("ClassA") + .typeVariables(Collections.singletonList( + TypeVariableInfo.builder() + .name("T") + .bounds(Collections.singletonList( + ConcreteTypeInfo.builder() + .qualifiedName("com.github.cuzfrog.Shape") + .simpleName("Shape") + .build() + )) + .build() + )) + .components(Collections.emptyList()) + .build(); + + when(ctxMocks.getContext().getTypeStore().getConfig(classDef)).thenReturn(config); + when(config.getTypescriptFieldReadonly()).thenReturn(Props.Typescript.FieldReadonlyType.NONE); + + var tuple = converter.convert(classDef); + assertThat(tuple).isNotNull(); + TypescriptInterfaceConverter.InterfaceExpr model = (TypescriptInterfaceConverter.InterfaceExpr) tuple.b(); + assertThat(model.name).isEqualTo("ClassA"); + assertThat(model.typeParameters).containsExactly("T extends Shape"); + } } From c61189aa2cf94cbd949152ef3a16d199e6c274ad Mon Sep 17 00:00:00 2001 From: Cause Cheng Date: Mon, 16 Mar 2026 17:18:01 -0400 Subject: [PATCH 2/2] fix: ignore optional container types in type parameter bounds --- .gemini/GEMINI.md | 1 + .../sharedtype/processor/parser/ClassTypeDefParser.java | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 .gemini/GEMINI.md diff --git a/.gemini/GEMINI.md b/.gemini/GEMINI.md new file mode 100644 index 00000000..e5e0d1af --- /dev/null +++ b/.gemini/GEMINI.md @@ -0,0 +1 @@ +# Project Instructions diff --git a/processor/src/main/java/online/sharedtype/processor/parser/ClassTypeDefParser.java b/processor/src/main/java/online/sharedtype/processor/parser/ClassTypeDefParser.java index 9e6a6005..5d33525a 100644 --- a/processor/src/main/java/online/sharedtype/processor/parser/ClassTypeDefParser.java +++ b/processor/src/main/java/online/sharedtype/processor/parser/ClassTypeDefParser.java @@ -108,7 +108,14 @@ private List parseBounds(TypeParameterElement typeParameterElement, Ty .filter(b -> !"java.lang.Object".equals(b.toString())) .filter(b -> { Element e = ctx.getProcessingEnv().getTypeUtils().asElement(b); - return e == null || !ctx.isIgnored(e); + if (e == null || ctx.isIgnored(e)) { + return false; + } + if (e instanceof TypeElement) { + TypeElement te = (TypeElement) e; + return !ctx.isOptionalType(te.getQualifiedName().toString()); + } + return true; }) .map(b -> typeInfoParser.parse(b, typeElement)) .collect(Collectors.toList());