From c4c6b029e3a09c1faefb496721102289d79911b2 Mon Sep 17 00:00:00 2001
From: Alexey Andreev <konsoletyper@gmail.com>
Date: Sun, 30 Jul 2023 20:48:29 +0200
Subject: [PATCH] JS: add support for exporting Java methods as JS properties

---
 .../org/teavm/jso/impl/JSAliasRenderer.java   | 100 +++++++++++++++---
 .../teavm/jso/impl/JSDependencyListener.java  |   6 ++
 .../org/teavm/jso/impl/JSGetterToExpose.java  |  27 +++++
 .../jso/impl/JSObjectClassTransformer.java    |  86 +++++++++++++--
 .../org/teavm/jso/impl/JSSetterToExpose.java  |  27 +++++
 .../java/org/teavm/jso/test/ExportClass.java  |  43 ++++++++
 6 files changed, 264 insertions(+), 25 deletions(-)
 create mode 100644 jso/impl/src/main/java/org/teavm/jso/impl/JSGetterToExpose.java
 create mode 100644 jso/impl/src/main/java/org/teavm/jso/impl/JSSetterToExpose.java

diff --git a/jso/impl/src/main/java/org/teavm/jso/impl/JSAliasRenderer.java b/jso/impl/src/main/java/org/teavm/jso/impl/JSAliasRenderer.java
index 591505801..1824fa399 100644
--- a/jso/impl/src/main/java/org/teavm/jso/impl/JSAliasRenderer.java
+++ b/jso/impl/src/main/java/org/teavm/jso/impl/JSAliasRenderer.java
@@ -17,7 +17,6 @@ package org.teavm.jso.impl;
 
 import java.io.IOException;
 import java.util.HashMap;
-import java.util.Map;
 import org.teavm.backend.javascript.codegen.SourceWriter;
 import org.teavm.backend.javascript.rendering.RenderingManager;
 import org.teavm.backend.javascript.spi.VirtualMethodContributor;
@@ -54,20 +53,35 @@ class JSAliasRenderer implements RendererListener, VirtualMethodContributor {
         writer.append("var c;").softNewLine();
         for (String className : classSource.getClassNames()) {
             ClassReader classReader = classSource.get(className);
-            Map<MethodDescriptor, String> methods = new HashMap<>();
-            for (MethodReader method : classReader.getMethods()) {
-                String methodAlias = getPublicAlias(method);
+            var methods = new HashMap<String, MethodDescriptor>();
+            var properties = new HashMap<String, PropertyInfo>();
+            for (var method : classReader.getMethods()) {
+                var methodAlias = getPublicAlias(method);
                 if (methodAlias != null) {
-                    methods.put(method.getDescriptor(), methodAlias);
+                    switch (methodAlias.kind) {
+                        case METHOD:
+                            methods.put(methodAlias.name, method.getDescriptor());
+                            break;
+                        case GETTER: {
+                            var propInfo = properties.computeIfAbsent(methodAlias.name, k -> new PropertyInfo());
+                            propInfo.getter = method.getDescriptor();
+                            break;
+                        }
+                        case SETTER: {
+                            var propInfo = properties.computeIfAbsent(methodAlias.name, k -> new PropertyInfo());
+                            propInfo.setter = method.getDescriptor();
+                            break;
+                        }
+                    }
                 }
             }
-            if (methods.isEmpty()) {
+            if (methods.isEmpty() && properties.isEmpty()) {
                 continue;
             }
 
             boolean first = true;
-            for (Map.Entry<MethodDescriptor, String> aliasEntry : methods.entrySet()) {
-                if (classReader.getMethod(aliasEntry.getKey()) == null) {
+            for (var aliasEntry : methods.entrySet()) {
+                if (classReader.getMethod(aliasEntry.getValue()) == null) {
                     continue;
                 }
                 if (first) {
@@ -75,12 +89,33 @@ class JSAliasRenderer implements RendererListener, VirtualMethodContributor {
                             .softNewLine();
                     first = false;
                 }
-                if (isKeyword(aliasEntry.getValue())) {
-                    writer.append("c[\"").append(aliasEntry.getValue()).append("\"]");
+                if (isKeyword(aliasEntry.getKey())) {
+                    writer.append("c[\"").append(aliasEntry.getKey()).append("\"]");
                 } else {
-                    writer.append("c.").append(aliasEntry.getValue());
+                    writer.append("c.").append(aliasEntry.getKey());
                 }
-                writer.ws().append("=").ws().append("c.").appendMethod(aliasEntry.getKey()).append(";").softNewLine();
+                writer.ws().append("=").ws().append("c.").appendMethod(aliasEntry.getValue())
+                        .append(";").softNewLine();
+            }
+            for (var aliasEntry : properties.entrySet()) {
+                var propInfo = aliasEntry.getValue();
+                if (propInfo.getter == null || classReader.getMethod(propInfo.getter) == null) {
+                    continue;
+                }
+                if (first) {
+                    writer.append("c").ws().append("=").ws().appendClass(className).append(".prototype;")
+                            .softNewLine();
+                    first = false;
+                }
+                writer.append("Object.defineProperty(c,")
+                        .ws().append("\"").append(aliasEntry.getKey()).append("\",")
+                        .ws().append("{").indent().softNewLine();
+                writer.append("get:").ws().append("c.").appendMethod(propInfo.getter);
+                if (propInfo.setter != null && classReader.getMethod(propInfo.setter) != null) {
+                    writer.append(",").softNewLine();
+                    writer.append("set:").ws().append("c.").appendMethod(propInfo.setter);
+                }
+                writer.softNewLine().outdent().append("});").softNewLine();
             }
 
             FieldReader functorField = getFunctorField(classReader);
@@ -101,9 +136,23 @@ class JSAliasRenderer implements RendererListener, VirtualMethodContributor {
         return false;
     }
 
-    private String getPublicAlias(MethodReader method) {
-        AnnotationReader annot = method.getAnnotations().get(JSMethodToExpose.class.getName());
-        return annot != null ? annot.getValue("name").getString() : null;
+    private Alias getPublicAlias(MethodReader method) {
+        var annot = method.getAnnotations().get(JSMethodToExpose.class.getName());
+        if (annot != null) {
+            return new Alias(annot.getValue("name").getString(), AliasKind.METHOD);
+        }
+
+        annot = method.getAnnotations().get(JSGetterToExpose.class.getName());
+        if (annot != null) {
+            return new Alias(annot.getValue("name").getString(), AliasKind.GETTER);
+        }
+
+        annot = method.getAnnotations().get(JSSetterToExpose.class.getName());
+        if (annot != null) {
+            return new Alias(annot.getValue("name").getString(), AliasKind.SETTER);
+        }
+
+        return null;
     }
 
     private FieldReader getFunctorField(ClassReader cls) {
@@ -190,4 +239,25 @@ class JSAliasRenderer implements RendererListener, VirtualMethodContributor {
         MethodReader methodReader = classReader.getMethod(methodRef.getDescriptor());
         return methodReader != null && getPublicAlias(methodReader) != null;
     }
+
+    static class PropertyInfo {
+        MethodDescriptor getter;
+        MethodDescriptor setter;
+    }
+
+    static class Alias {
+        final String name;
+        final AliasKind kind;
+
+        Alias(String name, AliasKind kind) {
+            this.name = name;
+            this.kind = kind;
+        }
+    }
+
+    enum AliasKind {
+        METHOD,
+        GETTER,
+        SETTER
+    }
 }
diff --git a/jso/impl/src/main/java/org/teavm/jso/impl/JSDependencyListener.java b/jso/impl/src/main/java/org/teavm/jso/impl/JSDependencyListener.java
index b8874ccba..90f6c79c9 100644
--- a/jso/impl/src/main/java/org/teavm/jso/impl/JSDependencyListener.java
+++ b/jso/impl/src/main/java/org/teavm/jso/impl/JSDependencyListener.java
@@ -48,6 +48,12 @@ class JSDependencyListener extends AbstractDependencyListener {
         ClassReader cls = agent.getClassSource().get(className);
         for (MethodReader method : cls.getMethods()) {
             AnnotationReader exposeAnnot = method.getAnnotations().get(JSMethodToExpose.class.getName());
+            if (exposeAnnot == null) {
+                exposeAnnot = method.getAnnotations().get(JSGetterToExpose.class.getName());
+            }
+            if (exposeAnnot == null) {
+                exposeAnnot = method.getAnnotations().get(JSSetterToExpose.class.getName());
+            }
             if (exposeAnnot != null) {
                 MethodDependency methodDep = agent.linkMethod(method.getReference());
                 methodDep.getVariable(0).propagate(agent.getType(className));
diff --git a/jso/impl/src/main/java/org/teavm/jso/impl/JSGetterToExpose.java b/jso/impl/src/main/java/org/teavm/jso/impl/JSGetterToExpose.java
new file mode 100644
index 000000000..65c3adf27
--- /dev/null
+++ b/jso/impl/src/main/java/org/teavm/jso/impl/JSGetterToExpose.java
@@ -0,0 +1,27 @@
+/*
+ *  Copyright 2017 Alexey Andreev.
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.teavm.jso.impl;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Retention(RetentionPolicy.CLASS)
+@Target(ElementType.METHOD)
+@interface JSGetterToExpose {
+    String name();
+}
diff --git a/jso/impl/src/main/java/org/teavm/jso/impl/JSObjectClassTransformer.java b/jso/impl/src/main/java/org/teavm/jso/impl/JSObjectClassTransformer.java
index 53de5b5c2..c8e45f009 100644
--- a/jso/impl/src/main/java/org/teavm/jso/impl/JSObjectClassTransformer.java
+++ b/jso/impl/src/main/java/org/teavm/jso/impl/JSObjectClassTransformer.java
@@ -25,6 +25,7 @@ import java.util.Set;
 import org.teavm.diagnostics.Diagnostics;
 import org.teavm.jso.JSMethod;
 import org.teavm.jso.JSObject;
+import org.teavm.jso.JSProperty;
 import org.teavm.model.AccessLevel;
 import org.teavm.model.AnnotationHolder;
 import org.teavm.model.AnnotationReader;
@@ -154,9 +155,21 @@ class JSObjectClassTransformer implements ClassHolderTransformer {
 
             classHolder.addMethod(exportedMethod);
 
-            String publicAlias = classToExpose.methods.get(method);
-            AnnotationHolder annot = new AnnotationHolder(JSMethodToExpose.class.getName());
-            annot.getValues().put("name", new AnnotationValue(publicAlias));
+            var export = classToExpose.methods.get(method);
+            String annotationName;
+            switch (export.kind) {
+                case GETTER:
+                    annotationName = JSGetterToExpose.class.getName();
+                    break;
+                case SETTER:
+                    annotationName = JSSetterToExpose.class.getName();
+                    break;
+                default:
+                    annotationName = JSMethodToExpose.class.getName();
+                    break;
+            }
+            AnnotationHolder annot = new AnnotationHolder(annotationName);
+            annot.getValues().put("name", new AnnotationValue(export.alias));
             exportedMethod.getAnnotations().add(annot);
 
             if (methodRef.equals(functorMethod)) {
@@ -189,8 +202,8 @@ class JSObjectClassTransformer implements ClassHolderTransformer {
         }
         if (cls.getParent() != null) {
             ExposedClass parent = getExposedClass(cls.getParent());
-            exposedCls.inheritedMethods.putAll(parent.inheritedMethods);
-            exposedCls.inheritedMethods.putAll(parent.methods);
+            exposedCls.inheritedMethods.addAll(parent.inheritedMethods);
+            exposedCls.inheritedMethods.addAll(parent.methods.keySet());
             exposedCls.implementedInterfaces.addAll(parent.implementedInterfaces);
         }
         addInterfaces(exposedCls, cls);
@@ -213,10 +226,12 @@ class JSObjectClassTransformer implements ClassHolderTransformer {
                             || (method.getProgram() != null && method.getProgram().basicBlockCount() > 0)) {
                         continue;
                     }
-                    if (!exposedCls.inheritedMethods.containsKey(method.getDescriptor())) {
-                        String name = method.getName();
+                    if (!exposedCls.inheritedMethods.contains(method.getDescriptor())) {
+                        String name = null;
+                        MethodKind kind = MethodKind.METHOD;
                         AnnotationReader methodAnnot = method.getAnnotations().get(JSMethod.class.getName());
                         if (methodAnnot != null) {
+                            name = method.getName();
                             AnnotationValue nameVal = methodAnnot.getValue("value");
                             if (nameVal != null) {
                                 String nameStr = nameVal.getString();
@@ -224,8 +239,43 @@ class JSObjectClassTransformer implements ClassHolderTransformer {
                                     name = nameStr;
                                 }
                             }
+                        } else {
+                            var propertyAnnot = method.getAnnotations().get(JSProperty.class.getName());
+                            if (propertyAnnot != null) {
+                                AnnotationValue nameVal = propertyAnnot.getValue("value");
+                                if (nameVal != null) {
+                                    String nameStr = nameVal.getString();
+                                    if (!nameStr.isEmpty()) {
+                                        name = nameStr;
+                                    }
+                                }
+                                String expectedPrefix;
+                                if (method.parameterCount() == 0) {
+                                    if (method.getResultType() == ValueType.BOOLEAN) {
+                                        expectedPrefix = "is";
+                                    } else {
+                                        expectedPrefix = "get";
+                                    }
+                                    kind = MethodKind.GETTER;
+                                } else {
+                                    expectedPrefix = "set";
+                                    kind = MethodKind.SETTER;
+                                }
+
+                                if (name == null) {
+                                    name = method.getName();
+                                    if (name.startsWith(expectedPrefix) && name.length() > expectedPrefix.length()
+                                            && Character.isUpperCase(name.charAt(expectedPrefix.length()))) {
+                                        name = Character.toLowerCase(name.charAt(expectedPrefix.length()))
+                                                + name.substring(expectedPrefix.length() + 1);
+                                    }
+                                }
+                            }
                         }
-                        exposedCls.methods.put(method.getDescriptor(), name);
+                        if (name == null) {
+                            name = method.getName();
+                        }
+                        exposedCls.methods.put(method.getDescriptor(), new MethodExport(name, kind));
                     }
                 }
             }
@@ -256,8 +306,24 @@ class JSObjectClassTransformer implements ClassHolderTransformer {
     }
 
     static class ExposedClass {
-        Map<MethodDescriptor, String> inheritedMethods = new HashMap<>();
-        Map<MethodDescriptor, String> methods = new HashMap<>();
+        Set<MethodDescriptor> inheritedMethods = new HashSet<>();
+        Map<MethodDescriptor, MethodExport> methods = new HashMap<>();
         Set<String> implementedInterfaces = new HashSet<>();
     }
+
+    enum MethodKind {
+        METHOD,
+        GETTER,
+        SETTER
+    }
+
+    static class MethodExport {
+        final String alias;
+        final MethodKind kind;
+
+        MethodExport(String alias, MethodKind kind) {
+            this.alias = alias;
+            this.kind = kind;
+        }
+    }
 }
diff --git a/jso/impl/src/main/java/org/teavm/jso/impl/JSSetterToExpose.java b/jso/impl/src/main/java/org/teavm/jso/impl/JSSetterToExpose.java
new file mode 100644
index 000000000..f64c71833
--- /dev/null
+++ b/jso/impl/src/main/java/org/teavm/jso/impl/JSSetterToExpose.java
@@ -0,0 +1,27 @@
+/*
+ *  Copyright 2017 Alexey Andreev.
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.teavm.jso.impl;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Retention(RetentionPolicy.CLASS)
+@Target(ElementType.METHOD)
+@interface JSSetterToExpose {
+    String name();
+}
diff --git a/tests/src/test/java/org/teavm/jso/test/ExportClass.java b/tests/src/test/java/org/teavm/jso/test/ExportClass.java
index 697fb328b..d44f87367 100644
--- a/tests/src/test/java/org/teavm/jso/test/ExportClass.java
+++ b/tests/src/test/java/org/teavm/jso/test/ExportClass.java
@@ -20,10 +20,13 @@ import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.teavm.jso.JSBody;
 import org.teavm.jso.JSObject;
+import org.teavm.jso.JSProperty;
 import org.teavm.junit.SkipJVM;
 import org.teavm.junit.TeaVMTestRunner;
+import org.teavm.junit.WholeClassCompilation;
 
 @RunWith(TeaVMTestRunner.class)
+@WholeClassCompilation
 @SkipJVM
 public class ExportClass {
     @Test
@@ -32,9 +35,24 @@ public class ExportClass {
         assertEquals("[OK]", callIFromJs(new DerivedSimpleClass()));
     }
 
+    @Test
+    public void classWithPropertiesExported() {
+        var o = new ClassWithProperty("q");
+        assertEquals("q", extractFoo(o));
+
+        setFoo(o);
+        assertEquals("w", o.fooValue);
+    }
+
     @JSBody(params = "a", script = "return a.foo('OK');")
     private static native String callIFromJs(I a);
 
+    @JSBody(params = "a", script = "return a.foo;")
+    private static native String extractFoo(J a);
+
+    @JSBody(params = "a", script = "a.foo = 'w';")
+    private static native String setFoo(J a);
+
     interface I extends JSObject {
         String foo(String a);
     }
@@ -53,4 +71,29 @@ public class ExportClass {
         }
     }
 
+    interface J extends JSObject {
+        @JSProperty
+        String getFoo();
+
+        @JSProperty
+        void setFoo(String value);
+    }
+
+    static class ClassWithProperty implements J {
+        String fooValue;
+
+        ClassWithProperty(String fooValue) {
+            this.fooValue = fooValue;
+        }
+
+        @Override
+        public String getFoo() {
+            return fooValue;
+        }
+
+        @Override
+        public void setFoo(String value) {
+            fooValue = value;
+        }
+    }
 }