diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..c965f19 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +build/ +**/build/ +local.properties +.kotlin/ diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..ac74ff9 --- /dev/null +++ b/android/README.md @@ -0,0 +1,53 @@ +# Android (KSP) macros + +The Android counterpart of the Swift macros in [`../apple`](../apple). Where Apple uses Swift +compiler macros, Android uses a [KSP](https://kotlinlang.org/docs/ksp-overview.html) (Kotlin Symbol +Processing) processor: the author annotates ordinary Kotlin, and the processor generates the Expo +module's JS surface at build time. + +## Why KSP, not a port of the Swift macros + +Swift macros rewrite the annotated declaration in place; KSP can only **generate new files**. So this +is a parallel implementation that lands on the same author-facing API, not a translation of the macro +code. (Pika, the IR plugin core uses for `@OptimizedRecord`, is sealed and can't be extended, so the +processor is first-party and Pika-independent.) + +## What's here + +- **`annotations/`** — the markers an author applies: `@ExpoModule` on a `Module` class, `@JS` on its + functions and properties. +- **`processor/`** — the KSP `SymbolProcessor`. It reads `@ExpoModule`/`@JS` and generates a + `.expoModuleDefinition()` extension that builds the module's `ModuleDefinitionData` through + the existing `ModuleDefinition { … }` DSL. + +## Author API + +```kotlin +@ExpoModule("MyModule") +class MyModule : Module() { + @JS fun greet(name: String): String = "hi $name" + @JS suspend fun work(id: String): Int = id.length + @JS val version: String get() = "1.0" + @JS var ready: Boolean = false + + // KSP can't add this override the way a Swift macro adds members to its type, + // so the author wires the generated definition in with one line. + override fun definition() = expoModuleDefinition() +} +``` + +The generated extension compiles against today's `expo-modules-core` with no core changes — the only +contract is that the generated DSL is valid against core, not that this tool shares core's compiler +version. A later step can add a core convention so `definition()` doesn't need to be written by hand. + +## Building and testing + +```sh +./gradlew :processor:test +``` + +Tests use [`kotlin-compile-testing`](https://github.com/ZacSweers/kotlin-compile-testing) (the KSP +analog of the Swift suite's `assertExpansion`): they run the processor over fixture modules and assert +on the generated source. The fixtures compile against minimal stubs of the core DSL, so a green run +proves the generated code is valid Kotlin that type-checks — it does **not** prove it links against +real core. Like the `apple/` suite, integration is only proven by building a real module against core. diff --git a/android/annotations/build.gradle.kts b/android/annotations/build.gradle.kts new file mode 100644 index 0000000..36924a5 --- /dev/null +++ b/android/annotations/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + kotlin("jvm") +} + +kotlin { + jvmToolchain(17) +} diff --git a/android/annotations/src/main/kotlin/expo/modules/macros/ExpoModule.kt b/android/annotations/src/main/kotlin/expo/modules/macros/ExpoModule.kt new file mode 100644 index 0000000..c0e9dfb --- /dev/null +++ b/android/annotations/src/main/kotlin/expo/modules/macros/ExpoModule.kt @@ -0,0 +1,29 @@ +package expo.modules.macros + +/** + * Marks a class as an Expo module whose JS surface is generated at build time. + * + * The processor reads the [JS]-annotated members in the class body and generates a + * `.expoModuleDefinition()` extension that builds the module's `ModuleDefinitionData` + * via the existing `ModuleDefinition { … }` DSL. The author wires it in with a one-line + * `definition()` override: + * + * ``` + * @ExpoModule("MyModule") + * class MyModule : Module() { + * @JS fun greet(name: String): String = "hi $name" + * @JS var ready: Boolean = false + * + * override fun definition() = expoModuleDefinition() + * } + * ``` + * + * This is the Android counterpart of the Swift `@ExpoModule` macro. KSP cannot add the + * `definition()` override to the class the way a Swift macro adds members to its type, so the + * generated definition is exposed as an extension the author returns from `definition()`. + * + * @param name JS module name. Defaults to the class's simple name when blank. + */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.SOURCE) +annotation class ExpoModule(val name: String = "") diff --git a/android/annotations/src/main/kotlin/expo/modules/macros/JS.kt b/android/annotations/src/main/kotlin/expo/modules/macros/JS.kt new file mode 100644 index 0000000..756e374 --- /dev/null +++ b/android/annotations/src/main/kotlin/expo/modules/macros/JS.kt @@ -0,0 +1,21 @@ +package expo.modules.macros + +/** + * Exposes a member of an [ExpoModule] class to JavaScript. + * + * Applied to functions and properties: + * - `@JS fun f(…)` becomes a synchronous `Function`. + * - `@JS suspend fun f(…)` becomes an asynchronous function returning a JS `Promise`. + * - `@JS val p` / `@JS var p` becomes a `Property`; a `var` (or a `val`/`var` with a setter) + * additionally gets a JS setter, a `val` stays read-only. + * + * `@JS("name")` overrides the JS-visible name; otherwise the Kotlin member name is used. + * + * The Android counterpart of the Swift `@JS` macro. Like its Swift sibling it applies to both + * functions and properties, not functions alone. + * + * @param name JS-visible name. Defaults to the member's Kotlin name when blank. + */ +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.SOURCE) +annotation class JS(val name: String = "") diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..b4e43dc --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,21 @@ +// The Android counterpart of the Swift macros in `apple/`. Instead of compiler macros, +// the JS surface is generated by a KSP (Kotlin Symbol Processing) processor: `:annotations` +// holds the markers an author applies (`@ExpoModule`, `@JS`), and `:processor` reads them and +// generates the module definition. +// +// Kotlin/KSP are pinned independently of `expo-modules-core` (which builds with Kotlin 2.0.21): +// this is a standalone tool whose only contract with core is that the *generated* code compiles +// against core's DSL, not that it shares a compiler version. 2.1.20 is chosen because it (and its +// matching KSP `2.1.20-2.0.1`) resolves from the local Gradle cache. + +plugins { + kotlin("jvm") version "2.1.20" apply false + id("com.google.devtools.ksp") version "2.1.20-2.0.1" apply false +} + +subprojects { + repositories { + mavenCentral() + google() + } +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..ed21a4b --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.caching=true +kotlin.code.style=official diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f78a6 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..fc8e709 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# 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 +# +# https://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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/processor/build.gradle.kts b/android/processor/build.gradle.kts new file mode 100644 index 0000000..49f7430 --- /dev/null +++ b/android/processor/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + kotlin("jvm") +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation(project(":annotations")) + implementation("com.google.devtools.ksp:symbol-processing-api:2.1.20-2.0.1") + + // `kotlin-compile-testing` (the maintained `kctfork` fork, which tracks modern KSP) is the + // KSP analog of the Swift macros' `assertExpansion`: it runs the processor over fixture + // sources in-process and lets the tests assert over the generated files. Like the `apple/` + // tests, this verifies the *shape* of the generated code, not that it links against real core. + testImplementation("dev.zacsweers.kctfork:core:0.7.0") + testImplementation("dev.zacsweers.kctfork:ksp:0.7.0") + testImplementation(kotlin("test")) + testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.named("compileTestKotlin") { + compilerOptions { + // `kotlin-compile-testing` exposes the compiler-plugin API, which is marked experimental. + optIn.add("org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi") + } +} + +tasks.test { + useJUnitPlatform() +} diff --git a/android/processor/src/main/kotlin/expo/modules/macros/processor/DefinitionGenerator.kt b/android/processor/src/main/kotlin/expo/modules/macros/processor/DefinitionGenerator.kt new file mode 100644 index 0000000..19d71cd --- /dev/null +++ b/android/processor/src/main/kotlin/expo/modules/macros/processor/DefinitionGenerator.kt @@ -0,0 +1,76 @@ +package expo.modules.macros.processor + +/** + * Turns a [ModuleModel] into the text of the generated `.expoModuleDefinition()` extension. + * + * The output builds the module's `ModuleDefinitionData` with the existing `ModuleDefinition { … }` + * DSL, so it compiles against today's `expo-modules-core` with no core changes — the author opts in + * by returning it from `definition()`. This mirrors what the Swift `@ExpoModule` macro synthesizes, + * the difference being that KSP emits a separate extension instead of adding a member to the class. + * + * Pure string building with no KSP dependency, so it is unit-testable on its own. + */ +internal object DefinitionGenerator { + /** + * The lambda passed to `ModuleDefinition { … }` runs with `ModuleDefinitionBuilder` as its + * receiver, so a bare `greet(…)` would resolve against the builder, not the module. Every call + * back into the module is qualified with this label, which names the enclosing extension's `this`. + */ + private const val RECEIVER = "this@expoModuleDefinition" + + fun generate(model: ModuleModel): String { + val body = buildString { + appendLine(" Name(\"${model.jsName}\")") + for (function in model.functions) { + appendLine(" " + functionEntry(function)) + } + for (property in model.properties) { + appendLine(" " + propertyEntry(property)) + } + }.trimEnd('\n') + + return buildString { + appendLine("// Generated by expo-modules-macros. Do not edit.") + appendLine("package ${model.packageName}") + appendLine() + appendLine("import expo.modules.kotlin.functions.Coroutine") + appendLine("import expo.modules.kotlin.modules.ModuleDefinition") + appendLine("import expo.modules.kotlin.modules.ModuleDefinitionData") + appendLine() + appendLine("internal fun ${model.simpleName}.expoModuleDefinition(): ModuleDefinitionData = ModuleDefinition {") + append(body) + appendLine() + appendLine("}") + } + } + + /** + * A sync `@JS fun` becomes `Function("name") { p0: T -> receiver.fn(p0) }`; a `suspend fun` becomes + * `AsyncFunction("name") Coroutine { p0: T -> receiver.fn(p0) }` (a JS Promise). The lambda + * parameters carry explicit types because the DSL infers each argument's converter from them. + */ + private fun functionEntry(function: FunctionModel): String { + val params = function.parameters.joinToString(", ") { "${it.name}: ${it.type}" } + val lambdaHeader = if (function.parameters.isEmpty()) "->" else "$params ->" + val args = function.parameters.joinToString(", ") { it.name } + val call = "$RECEIVER.${function.kotlinName}($args)" + return if (function.isSuspend) { + "AsyncFunction(\"${function.jsName}\") Coroutine { $lambdaHeader $call }" + } else { + "Function(\"${function.jsName}\") { $lambdaHeader $call }" + } + } + + /** + * A `@JS val` becomes `Property("name").get { receiver.p }`; a `@JS var` additionally gets + * `.set { value: T -> receiver.p = value }`. The setter's value type is spelled explicitly so the + * builder's reified `set` resolves the converter, the same reason function lambdas are typed. + */ + private fun propertyEntry(property: PropertyModel): String { + val getter = "Property(\"${property.jsName}\").get { $RECEIVER.${property.kotlinName} }" + if (!property.isMutable) { + return getter + } + return "$getter.set { value: ${property.type} -> $RECEIVER.${property.kotlinName} = value }" + } +} diff --git a/android/processor/src/main/kotlin/expo/modules/macros/processor/ExpoModuleProcessor.kt b/android/processor/src/main/kotlin/expo/modules/macros/processor/ExpoModuleProcessor.kt new file mode 100644 index 0000000..d118bc8 --- /dev/null +++ b/android/processor/src/main/kotlin/expo/modules/macros/processor/ExpoModuleProcessor.kt @@ -0,0 +1,143 @@ +package expo.modules.macros.processor + +import com.google.devtools.ksp.getDeclaredFunctions +import com.google.devtools.ksp.getDeclaredProperties +import com.google.devtools.ksp.validate +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.symbol.ClassKind +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSPropertyDeclaration +import com.google.devtools.ksp.symbol.Modifier + +/** + * Reads `@ExpoModule` classes and their `@JS` members and generates a `.expoModuleDefinition()` + * extension per module (see [DefinitionGenerator]). The Android counterpart of the Swift + * `ExpoModuleMacro`: same job (discover the JS surface from annotations, emit the definition), done + * with KSP's resolved symbols instead of SwiftSyntax. + * + * Type-convertibility is *not* asserted here the way the Swift `@JS` macro emits a peer assertion — + * KSP hands us resolved types, and a non-JS-convertible argument/return type already fails to compile + * at the generated DSL call site (the reified `Function`/`Property` builders can't resolve a + * `TypeConverter` for it), with the error pointing at the generated lambda. + */ +class ExpoModuleProcessor( + private val codeGenerator: CodeGenerator, + private val logger: KSPLogger +) : SymbolProcessor { + override fun process(resolver: Resolver): List { + val symbols = resolver.getSymbolsWithAnnotation(EXPO_MODULE_ANNOTATION).toList() + val deferred = symbols.filterNot { it.validate() } + + symbols + .filterIsInstance() + .filter { it.validate() } + .forEach { processModule(it) } + + return deferred + } + + private fun processModule(declaration: KSClassDeclaration) { + if (declaration.classKind != ClassKind.CLASS) { + logger.error("@ExpoModule can only be applied to a class", declaration) + return + } + if (!extendsModule(declaration)) { + logger.error( + "@ExpoModule class '${declaration.simpleName.asString()}' must extend ${MODULE_CLASS}", + declaration + ) + return + } + + val model = buildModel(declaration) ?: return + writeDefinition(declaration, model) + } + + private fun buildModel(declaration: KSClassDeclaration): ModuleModel? { + val packageName = declaration.packageName.asString() + val simpleName = declaration.simpleName.asString() + val jsName = declaration.expoModuleName().ifBlank { simpleName } + + val annotatedFunctions = declaration.getDeclaredFunctions() + .filter { it.hasAnnotation(JS_ANNOTATION) } + .toList() + val annotatedProperties = declaration.getDeclaredProperties() + .filter { it.hasAnnotation(JS_ANNOTATION) } + .toList() + + val functions = annotatedFunctions.mapNotNull { functionModel(it) } + val properties = annotatedProperties.mapNotNull { propertyModel(it) } + + // A member that failed validation has already logged an error (which fails the build). Don't + // also emit a definition that omits it — a partial file would only add noise on top of the + // real diagnostic. + if (functions.size != annotatedFunctions.size || properties.size != annotatedProperties.size) { + return null + } + + return ModuleModel( + qualifiedName = declaration.qualifiedName?.asString() ?: "$packageName.$simpleName", + packageName = packageName, + simpleName = simpleName, + jsName = jsName, + functions = functions, + properties = properties + ) + } + + private fun functionModel(function: KSFunctionDeclaration): FunctionModel? { + val kotlinName = function.simpleName.asString() + if (function.modifiers.contains(Modifier.PRIVATE)) { + logger.error("@JS function '$kotlinName' cannot be private — the generated definition is in the same package but can't see private members", function) + return null + } + val parameters = function.parameters.map { parameter -> + ParameterModel( + name = parameter.name?.asString() ?: "_", + type = parameter.type.resolve().toTypeName() + ) + } + return FunctionModel( + kotlinName = kotlinName, + jsName = function.jsName(kotlinName), + parameters = parameters, + isSuspend = function.modifiers.contains(Modifier.SUSPEND) + ) + } + + private fun propertyModel(property: KSPropertyDeclaration): PropertyModel? { + val kotlinName = property.simpleName.asString() + if (property.modifiers.contains(Modifier.PRIVATE)) { + logger.error("@JS property '$kotlinName' cannot be private — the generated definition is in the same package but can't see private members", property) + return null + } + return PropertyModel( + kotlinName = kotlinName, + jsName = property.jsName(kotlinName), + type = property.type.resolve().toTypeName(), + isMutable = property.isMutable + ) + } + + private fun writeDefinition(declaration: KSClassDeclaration, model: ModuleModel) { + val source = DefinitionGenerator.generate(model) + val file = codeGenerator.createNewFile( + dependencies = Dependencies(aggregating = false, declaration.containingFile!!), + packageName = model.packageName, + fileName = "${model.simpleName}\$ExpoModuleDefinition" + ) + file.bufferedWriter().use { it.write(source) } + } + + companion object { + private const val EXPO_MODULE_ANNOTATION = "expo.modules.macros.ExpoModule" + private const val JS_ANNOTATION = "expo.modules.macros.JS" + private const val MODULE_CLASS = "expo.modules.kotlin.modules.Module" + } +} diff --git a/android/processor/src/main/kotlin/expo/modules/macros/processor/ExpoModuleProcessorProvider.kt b/android/processor/src/main/kotlin/expo/modules/macros/processor/ExpoModuleProcessorProvider.kt new file mode 100644 index 0000000..57b8c9c --- /dev/null +++ b/android/processor/src/main/kotlin/expo/modules/macros/processor/ExpoModuleProcessorProvider.kt @@ -0,0 +1,16 @@ +package expo.modules.macros.processor + +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider + +/** + * KSP entry point. Registered via `META-INF/services` so the Kotlin compiler discovers it, this is + * the Android analog of the Swift `Plugin.swift` `providingMacros` list — the single place the + * toolchain hooks into to run our processing. + */ +class ExpoModuleProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + return ExpoModuleProcessor(environment.codeGenerator, environment.logger) + } +} diff --git a/android/processor/src/main/kotlin/expo/modules/macros/processor/KspExtensions.kt b/android/processor/src/main/kotlin/expo/modules/macros/processor/KspExtensions.kt new file mode 100644 index 0000000..237a352 --- /dev/null +++ b/android/processor/src/main/kotlin/expo/modules/macros/processor/KspExtensions.kt @@ -0,0 +1,73 @@ +package expo.modules.macros.processor + +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.Nullability + +/** True if the declaration carries an annotation with the given fully-qualified name. */ +internal fun KSDeclaration.hasAnnotation(qualifiedName: String): Boolean { + return annotations.any { + it.annotationType.resolve().declaration.qualifiedName?.asString() == qualifiedName + } +} + +/** + * The JS name for a `@JS` member: the annotation's `name` argument when non-blank, else the given + * Kotlin member name. Reading the argument by name (not position) keeps it robust to the annotation + * gaining more parameters later. + */ +internal fun KSDeclaration.jsName(fallback: String): String { + return jsNameArgument().ifBlank { fallback } +} + +private fun KSDeclaration.jsNameArgument(): String { + val annotation = annotations.firstOrNull { + it.annotationType.resolve().declaration.qualifiedName?.asString() == "expo.modules.macros.JS" + } ?: return "" + val value = annotation.arguments.firstOrNull { it.name?.asString() == "name" }?.value + return (value as? String).orEmpty() +} + +/** The `name` argument of `@ExpoModule`, or blank when omitted. */ +internal fun KSClassDeclaration.expoModuleName(): String { + val annotation = annotations.firstOrNull { + it.annotationType.resolve().declaration.qualifiedName?.asString() == "expo.modules.macros.ExpoModule" + } ?: return "" + val value = annotation.arguments.firstOrNull { it.name?.asString() == "name" }?.value + return (value as? String).orEmpty() +} + +/** True if the class transitively extends `expo.modules.kotlin.modules.Module`. */ +internal fun extendsModule(declaration: KSClassDeclaration): Boolean { + return declaration.getAllSuperTypes().any { + it.declaration.qualifiedName?.asString() == "expo.modules.kotlin.modules.Module" + } +} + +private fun KSClassDeclaration.getAllSuperTypes(): Sequence { + return superTypes + .map { it.resolve() } + .flatMap { superType -> + val declaration = superType.declaration + val transitive = if (declaration is KSClassDeclaration) { + declaration.getAllSuperTypes() + } else { + emptySequence() + } + sequenceOf(superType) + transitive + } +} + +/** + * Renders a resolved type as source the generated file can use: fully-qualified name plus a trailing + * `?` when nullable. Qualified names avoid having to manage imports for arbitrary parameter/property + * types in the generated file. Type arguments are intentionally not rendered yet — the first cut + * targets simple types; generic argument support is a follow-up. + */ +internal fun KSType.toTypeName(): String { + val qualified = declaration.qualifiedName?.asString() + ?: declaration.simpleName.asString() + val suffix = if (nullability == Nullability.NULLABLE) "?" else "" + return qualified + suffix +} diff --git a/android/processor/src/main/kotlin/expo/modules/macros/processor/ModuleModel.kt b/android/processor/src/main/kotlin/expo/modules/macros/processor/ModuleModel.kt new file mode 100644 index 0000000..4bf67c9 --- /dev/null +++ b/android/processor/src/main/kotlin/expo/modules/macros/processor/ModuleModel.kt @@ -0,0 +1,55 @@ +package expo.modules.macros.processor + +/** + * The resolved description of one `@ExpoModule` class, built from the KSP symbols and consumed by + * [DefinitionGenerator]. Keeping the generator off the KSP API makes the generated-code logic plain + * to read and unit-testable in isolation. + */ +internal data class ModuleModel( + /** Fully-qualified name of the annotated class, e.g. `com.example.MyModule`. */ + val qualifiedName: String, + /** Package the generated file is emitted into (shared with the module so it can call the result). */ + val packageName: String, + /** Simple class name, e.g. `MyModule`. */ + val simpleName: String, + /** JS module name: the `@ExpoModule("…")` argument, or the simple class name when blank. */ + val jsName: String, + val functions: List, + val properties: List +) + +/** A `@JS`-annotated function. */ +internal data class FunctionModel( + /** Kotlin function name — used to call it from the generated lambda. */ + val kotlinName: String, + /** JS-visible name: the `@JS("…")` argument, or [kotlinName] when blank. */ + val jsName: String, + /** Parameters in order, used to type and forward the generated lambda. */ + val parameters: List, + /** True for `suspend fun` — generated as an `AsyncFunction … Coroutine { }` returning a Promise. */ + val isSuspend: Boolean +) + +/** + * One function parameter. The generated `Function`/`AsyncFunction` DSL infers each argument's + * `TypeConverter` from the closure's parameter types via reified generics, so the lambda must spell + * the type out — Kotlin can't infer a lambda parameter type from how the body uses it. + */ +internal data class ParameterModel( + val name: String, + /** Fully-qualified, nullability-preserving type, e.g. `kotlin.String`, `kotlin.Int?`. */ + val type: String +) + +/** A `@JS`-annotated property (`val`/`var`). */ +internal data class PropertyModel( + /** Kotlin property name — used to read/write it from the generated accessors. */ + val kotlinName: String, + /** JS-visible name: the `@JS("…")` argument, or [kotlinName] when blank. */ + val jsName: String, + /** Fully-qualified, nullability-preserving type. Spelled on the generated setter's value param so + * the builder's reified `set` resolves the converter without relying on backward inference. */ + val type: String, + /** True when the property is mutable (`var`, or a `val`/`var` with an explicit setter). */ + val isMutable: Boolean +) diff --git a/android/processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/android/processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 0000000..f9984ee --- /dev/null +++ b/android/processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +expo.modules.macros.processor.ExpoModuleProcessorProvider diff --git a/android/processor/src/test/kotlin/expo/modules/macros/processor/CoreStubs.kt b/android/processor/src/test/kotlin/expo/modules/macros/processor/CoreStubs.kt new file mode 100644 index 0000000..3f8ee5a --- /dev/null +++ b/android/processor/src/test/kotlin/expo/modules/macros/processor/CoreStubs.kt @@ -0,0 +1,87 @@ +package expo.modules.macros.processor + +import com.tschuchort.compiletesting.SourceFile + +/** + * Minimal stand-ins for the `expo-modules-core` symbols the fixtures and the generated code touch. + * + * The real core isn't on the test classpath (these tests are shape-only, like the `apple/` suite), + * but compiling the generated file against a faithful *shape* of the DSL is stronger than diffing + * text: it proves the generated Kotlin is syntactically valid and type-checks against the DSL it + * targets. The signatures mirror `ObjectDefinitionBuilder`/`PropertyComponentBuilder`/ + * `AsyncFunctionBuilder` closely enough for that check (reified `Function`/`AsyncFunction`/ + * `Property`, the `Coroutine` infix, a `Module` base with an abstract `definition()`). + */ +internal val coreStub = SourceFile.kotlin( + "CoreStub.kt", + """ + package expo.modules.kotlin.modules + + abstract class Module { + abstract fun definition(): ModuleDefinitionData + } + + class ModuleDefinitionData + + class ModuleDefinitionBuilder { + fun Name(name: String) {} + + fun Function(name: String, body: () -> R) {} + fun Function(name: String, body: (P0) -> R) {} + fun Function(name: String, body: (P0, P1) -> R) {} + + fun AsyncFunction(name: String, body: () -> R) {} + fun AsyncFunction(name: String, body: (P0) -> R) = expo.modules.kotlin.functions.AsyncFunctionBuilder() + fun AsyncFunction(name: String) = expo.modules.kotlin.functions.AsyncFunctionBuilder() + + fun Property(name: String) = PropertyComponentBuilder(name) + } + + class PropertyComponentBuilder(val name: String) { + fun get(body: () -> R) = this + fun set(body: (T) -> Unit) = this + } + + inline fun Module.ModuleDefinition(block: ModuleDefinitionBuilder.() -> Unit): ModuleDefinitionData { + ModuleDefinitionBuilder().block() + return ModuleDefinitionData() + } + """.trimIndent() +) + +internal val asyncBuilderStub = SourceFile.kotlin( + "AsyncFunctionBuilderStub.kt", + """ + package expo.modules.kotlin.functions + + class AsyncFunctionBuilder + + infix fun AsyncFunctionBuilder.Coroutine(block: suspend () -> R) {} + infix fun AsyncFunctionBuilder.Coroutine(block: suspend (P0) -> R) {} + infix fun AsyncFunctionBuilder.Coroutine(block: suspend (P0, P1) -> R) {} + """.trimIndent() +) + +/** The real annotation sources, so fixtures can apply `@ExpoModule` / `@JS`. */ +internal val annotationStubs = listOf( + SourceFile.kotlin( + "ExpoModuleAnnotation.kt", + """ + package expo.modules.macros + + @Target(AnnotationTarget.CLASS) + @Retention(AnnotationRetention.SOURCE) + annotation class ExpoModule(val name: String = "") + """.trimIndent() + ), + SourceFile.kotlin( + "JSAnnotation.kt", + """ + package expo.modules.macros + + @Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY) + @Retention(AnnotationRetention.SOURCE) + annotation class JS(val name: String = "") + """.trimIndent() + ) +) diff --git a/android/processor/src/test/kotlin/expo/modules/macros/processor/DefinitionGeneratorTest.kt b/android/processor/src/test/kotlin/expo/modules/macros/processor/DefinitionGeneratorTest.kt new file mode 100644 index 0000000..36bf949 --- /dev/null +++ b/android/processor/src/test/kotlin/expo/modules/macros/processor/DefinitionGeneratorTest.kt @@ -0,0 +1,160 @@ +package expo.modules.macros.processor + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Unit tests over [DefinitionGenerator] alone (no compiler) — fast, exact assertions on the + * generated text. The end-to-end "does it actually compile" check lives in + * [ExpoModuleProcessorTest]. + */ +class DefinitionGeneratorTest { + private fun model( + functions: List = emptyList(), + properties: List = emptyList(), + jsName: String = "MyModule" + ) = ModuleModel( + qualifiedName = "com.example.MyModule", + packageName = "com.example", + simpleName = "MyModule", + jsName = jsName, + functions = functions, + properties = properties + ) + + @Test + fun `emits package, imports and the extension signature`() { + val source = DefinitionGenerator.generate(model()) + assertTrue(source.contains("package com.example"), source) + assertTrue(source.contains("import expo.modules.kotlin.modules.ModuleDefinition"), source) + assertTrue( + source.contains("internal fun MyModule.expoModuleDefinition(): ModuleDefinitionData = ModuleDefinition {"), + source + ) + assertTrue(source.contains("Name(\"MyModule\")"), source) + } + + @Test + fun `uses the JS module name for Name`() { + val source = DefinitionGenerator.generate(model(jsName = "CustomName")) + assertTrue(source.contains("Name(\"CustomName\")"), source) + } + + @Test + fun `sync function with no args`() { + val source = DefinitionGenerator.generate( + model(functions = listOf(FunctionModel("reset", "reset", emptyList(), isSuspend = false))) + ) + assertTrue( + source.contains("Function(\"reset\") { -> this@expoModuleDefinition.reset() }"), + source + ) + } + + @Test + fun `sync function with typed args forwards by name`() { + val source = DefinitionGenerator.generate( + model( + functions = listOf( + FunctionModel( + kotlinName = "greet", + jsName = "greet", + parameters = listOf( + ParameterModel("name", "kotlin.String"), + ParameterModel("times", "kotlin.Int") + ), + isSuspend = false + ) + ) + ) + ) + assertTrue( + source.contains( + "Function(\"greet\") { name: kotlin.String, times: kotlin.Int -> this@expoModuleDefinition.greet(name, times) }" + ), + source + ) + } + + @Test + fun `suspend function becomes AsyncFunction Coroutine`() { + val source = DefinitionGenerator.generate( + model( + functions = listOf( + FunctionModel( + kotlinName = "work", + jsName = "work", + parameters = listOf(ParameterModel("id", "kotlin.String")), + isSuspend = true + ) + ) + ) + ) + assertTrue( + source.contains( + "AsyncFunction(\"work\") Coroutine { id: kotlin.String -> this@expoModuleDefinition.work(id) }" + ), + source + ) + } + + @Test + fun `read-only property emits a getter only`() { + val source = DefinitionGenerator.generate( + model(properties = listOf(PropertyModel("version", "version", "kotlin.String", isMutable = false))) + ) + assertTrue( + source.contains("Property(\"version\").get { this@expoModuleDefinition.version }"), + source + ) + assertFalse(source.contains(".set"), source) + } + + @Test + fun `mutable property emits getter and typed setter`() { + val source = DefinitionGenerator.generate( + model(properties = listOf(PropertyModel("ready", "ready", "kotlin.Boolean", isMutable = true))) + ) + assertTrue( + source.contains( + "Property(\"ready\").get { this@expoModuleDefinition.ready }.set { value: kotlin.Boolean -> this@expoModuleDefinition.ready = value }" + ), + source + ) + } + + @Test + fun `JS name override is used for the wire name but the kotlin name for the call`() { + val source = DefinitionGenerator.generate( + model( + functions = listOf( + FunctionModel("performWork", "doWork", emptyList(), isSuspend = false) + ) + ) + ) + assertTrue( + source.contains("Function(\"doWork\") { -> this@expoModuleDefinition.performWork() }"), + source + ) + } + + @Test + fun `empty module still produces a valid definition`() { + val source = DefinitionGenerator.generate(model()) + val expected = """ + |// Generated by expo-modules-macros. Do not edit. + |package com.example + | + |import expo.modules.kotlin.functions.Coroutine + |import expo.modules.kotlin.modules.ModuleDefinition + |import expo.modules.kotlin.modules.ModuleDefinitionData + | + |internal fun MyModule.expoModuleDefinition(): ModuleDefinitionData = ModuleDefinition { + | Name("MyModule") + |} + """.trimMargin() + "\n" + assertEquals(expected, source) + } +} diff --git a/android/processor/src/test/kotlin/expo/modules/macros/processor/ExpoModuleProcessorTest.kt b/android/processor/src/test/kotlin/expo/modules/macros/processor/ExpoModuleProcessorTest.kt new file mode 100644 index 0000000..4a224cd --- /dev/null +++ b/android/processor/src/test/kotlin/expo/modules/macros/processor/ExpoModuleProcessorTest.kt @@ -0,0 +1,187 @@ +package expo.modules.macros.processor + +import com.tschuchort.compiletesting.KotlinCompilation +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * End-to-end tests: run [ExpoModuleProcessor] over a fixture module and assert on both the generated + * source and the compile outcome. Because the generated file is compiled against the core stubs in + * the same run, a green compilation proves the generated DSL is valid Kotlin that type-checks — the + * shape check the Swift suite does, with a real compile on top. + */ +class ExpoModuleProcessorTest { + @Test + fun `generates a definition for a module with sync, suspend and property members`() { + val result = compileWithProcessor( + fixture( + "MyModule.kt", + """ + package com.example + + import expo.modules.kotlin.modules.Module + import expo.modules.kotlin.modules.ModuleDefinitionData + import expo.modules.macros.ExpoModule + import expo.modules.macros.JS + + @ExpoModule("MyModule") + class MyModule : Module() { + @JS fun greet(name: String): String = "hi ${'$'}name" + @JS fun reset() {} + @JS suspend fun work(id: String): Int = id.length + @JS val version: String get() = "1.0" + @JS var ready: Boolean = false + + override fun definition(): ModuleDefinitionData = expoModuleDefinition() + } + """.trimIndent() + ) + ) + + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + + val generated = result.singleGeneratedFile().collapseWhitespace() + assertContains(generated, "Name(\"MyModule\")") + assertContains(generated, "Function(\"greet\") { name: kotlin.String -> this@expoModuleDefinition.greet(name) }") + assertContains(generated, "Function(\"reset\") { -> this@expoModuleDefinition.reset() }") + assertContains(generated, "AsyncFunction(\"work\") Coroutine { id: kotlin.String -> this@expoModuleDefinition.work(id) }") + assertContains(generated, "Property(\"version\").get { this@expoModuleDefinition.version }") + assertContains( + generated, + "Property(\"ready\").get { this@expoModuleDefinition.ready }.set { value: kotlin.Boolean -> this@expoModuleDefinition.ready = value }" + ) + } + + @Test + fun `module name defaults to the class name when omitted`() { + val result = compileWithProcessor( + fixture( + "Defaulted.kt", + """ + package com.example + + import expo.modules.kotlin.modules.Module + import expo.modules.kotlin.modules.ModuleDefinitionData + import expo.modules.macros.ExpoModule + + @ExpoModule + class Defaulted : Module() { + override fun definition(): ModuleDefinitionData = expoModuleDefinition() + } + """.trimIndent() + ) + ) + + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + assertContains(result.singleGeneratedFile(), "Name(\"Defaulted\")") + } + + @Test + fun `JS name override sets the wire name but keeps the kotlin call`() { + val result = compileWithProcessor( + fixture( + "Renamed.kt", + """ + package com.example + + import expo.modules.kotlin.modules.Module + import expo.modules.kotlin.modules.ModuleDefinitionData + import expo.modules.macros.ExpoModule + import expo.modules.macros.JS + + @ExpoModule + class Renamed : Module() { + @JS("doWork") fun performWork() {} + + override fun definition(): ModuleDefinitionData = expoModuleDefinition() + } + """.trimIndent() + ) + ) + + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + assertContains( + result.singleGeneratedFile().collapseWhitespace(), + "Function(\"doWork\") { -> this@expoModuleDefinition.performWork() }" + ) + } + + @Test + fun `nullable parameter and property types are preserved`() { + val result = compileWithProcessor( + fixture( + "Nullable.kt", + """ + package com.example + + import expo.modules.kotlin.modules.Module + import expo.modules.kotlin.modules.ModuleDefinitionData + import expo.modules.macros.ExpoModule + import expo.modules.macros.JS + + @ExpoModule + class Nullable : Module() { + @JS fun maybe(value: String?) {} + @JS var note: String? = null + + override fun definition(): ModuleDefinitionData = expoModuleDefinition() + } + """.trimIndent() + ) + ) + + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + val generated = result.singleGeneratedFile().collapseWhitespace() + assertContains(generated, "Function(\"maybe\") { value: kotlin.String? -> this@expoModuleDefinition.maybe(value) }") + assertContains(generated, "set { value: kotlin.String? -> this@expoModuleDefinition.note = value }") + } + + @Test + fun `errors when @ExpoModule class does not extend Module`() { + val result = compileWithProcessor( + fixture( + "NotAModule.kt", + """ + package com.example + + import expo.modules.macros.ExpoModule + + @ExpoModule + class NotAModule + """.trimIndent() + ) + ) + + assertEquals(KotlinCompilation.ExitCode.COMPILATION_ERROR, result.exitCode, result.messages) + assertTrue(result.messages.contains("must extend expo.modules.kotlin.modules.Module"), result.messages) + } + + @Test + fun `errors when a @JS member is private`() { + val result = compileWithProcessor( + fixture( + "PrivateMember.kt", + """ + package com.example + + import expo.modules.kotlin.modules.Module + import expo.modules.kotlin.modules.ModuleDefinitionData + import expo.modules.macros.ExpoModule + import expo.modules.macros.JS + + @ExpoModule + class PrivateMember : Module() { + @JS private fun secret() {} + + override fun definition(): ModuleDefinitionData = ModuleDefinitionData() + } + """.trimIndent() + ) + ) + + assertEquals(KotlinCompilation.ExitCode.COMPILATION_ERROR, result.exitCode, result.messages) + assertTrue(result.messages.contains("cannot be private"), result.messages) + } +} diff --git a/android/processor/src/test/kotlin/expo/modules/macros/processor/ProcessorTestSupport.kt b/android/processor/src/test/kotlin/expo/modules/macros/processor/ProcessorTestSupport.kt new file mode 100644 index 0000000..c8a736b --- /dev/null +++ b/android/processor/src/test/kotlin/expo/modules/macros/processor/ProcessorTestSupport.kt @@ -0,0 +1,48 @@ +package expo.modules.macros.processor + +import com.tschuchort.compiletesting.JvmCompilationResult +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.configureKsp +import com.tschuchort.compiletesting.kspSourcesDir +import java.io.File + +/** + * Runs [ExpoModuleProcessor] over a fixture plus the core/annotation stubs and returns the + * compilation result. The KSP analog of the Swift macros' `assertExpansion` harness: process + * in-process, then assert over the generated sources and/or the compile outcome. + */ +internal fun compileWithProcessor(vararg fixtures: SourceFile): JvmCompilationResult { + val compilation = KotlinCompilation().apply { + sources = annotationStubs + coreStub + asyncBuilderStub + fixtures.toList() + configureKsp(useKsp2 = true) { + symbolProcessorProviders += ExpoModuleProcessorProvider() + } + inheritClassPath = true + messageOutputStream = System.out + } + return compilation.compile() +} + +/** All files KSP generated during the run, read back as text for shape assertions. */ +internal fun JvmCompilationResult.generatedKotlin(): Map { + val generatedDir = outputDirectory.parentFile.resolve("ksp/sources/kotlin") + if (!generatedDir.exists()) { + return emptyMap() + } + return generatedDir.walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .associate { it.name to it.readText() } +} + +/** Convenience: the single generated definition file's text, failing if there isn't exactly one. */ +internal fun JvmCompilationResult.singleGeneratedFile(): String { + val generated = generatedKotlin() + check(generated.size == 1) { "expected exactly one generated file, got ${generated.keys}" } + return generated.values.first() +} + +internal fun fixture(name: String, contents: String): SourceFile = SourceFile.kotlin(name, contents) + +/** Normalizes whitespace so shape assertions aren't brittle to indentation. */ +internal fun String.collapseWhitespace(): String = trim().replace(Regex("\\s+"), " ") diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..4c08cb7 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,19 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + google() + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + google() + } +} + +rootProject.name = "expo-modules-macros" + +include(":annotations") +include(":processor")