Initial Jerboa Android generator

ober

11723843296d93c0a6d5b9ee43044aea9e7a0ab4

diff --git a/.build.yml b/.build.yml
new file mode 100644
index 0000000..83b561d
--- /dev/null
+++ b/.build.yml
@@ -0,0 +1,18 @@
+image: alpine/latest
+packages:
+  - bash
+  - build-base
+  - git
+  - make
+  - openssl-dev
+  - zlib-dev
+sources:
+  - https://git.sr.ht/~lisp/jerboa
+  - https://git.sr.ht/~lisp/jerboa-android
+tasks:
+  - jerboa: |
+      cd jerboa
+      make binary
+  - smoke: |
+      cd jerboa-android
+      JERBOA=../jerboa/jerboa-bin make test
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c625897
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+build/
+.gradle/
+local.properties
+*.apk
+*.aab
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..69b3d24
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,25 @@
+JERBOA ?= jerboa
+BUILD_DIR ?= build/counter
+EXAMPLE ?= examples/counter/app.ss
+
+.PHONY: help clean generate test
+
+help:
+	@echo "Targets:"
+	@echo "  generate  Generate the counter Android project"
+	@echo "  test      Run the local generator smoke test"
+	@echo "  clean     Remove generated output"
+
+clean:
+	rm -rf build
+
+generate:
+	$(JERBOA) jandroid.ss generate $(EXAMPLE) $(BUILD_DIR)
+
+test: clean generate
+	test -f $(BUILD_DIR)/settings.gradle.kts
+	test -f $(BUILD_DIR)/app/build.gradle.kts
+	test -f $(BUILD_DIR)/app/src/main/AndroidManifest.xml
+	test -f $(BUILD_DIR)/app/src/main/java/org/jerboa/counter/MainActivity.kt
+	grep -q 'class MainActivity' $(BUILD_DIR)/app/src/main/java/org/jerboa/counter/MainActivity.kt
+	grep -q 'count = (count + 1); render()' $(BUILD_DIR)/app/src/main/java/org/jerboa/counter/MainActivity.kt
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..daa3a9a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,66 @@
+# jerboa-android
+
+`jerboa-android` is an early Android project generator for Jerboa.
+
+The first target is deliberately small: read a quoted Jerboa app spec from a
+`.ss` file and emit a plain Kotlin/Android Gradle project. Jerboa runs at build
+time on the developer machine; the generated APK contains ordinary Android
+code.
+
+## Quick Start
+
+```sh
+make test
+make generate
+```
+
+The generated counter project is written to `build/counter`.
+
+```sh
+cd build/counter
+gradle assembleDebug
+```
+
+The generated project uses Android Gradle Plugin 9.2.0 and the built-in Kotlin
+support introduced in AGP 9.x.
+
+## App Spec Shape
+
+Specs are valid Jerboa files that define a quoted `app` value:
+
+```scheme
+(import (jerboa prelude))
+
+(def app
+  '(android-app
+     (id "org.jerboa.counter")
+     (name "Jerboa Counter")
+     (screen Main
+       (state count 0)
+       (column
+         (text "Count: " count)
+         (button "Increment" (set count (+ count 1)))))))
+```
+
+Current supported view forms:
+
+- `(column child ...)`
+- `(row child ...)`
+- `(text part ...)`
+- `(button label action ...)`
+- `(spacer height)`
+
+Current supported action form:
+
+- `(set state expression)`
+
+Current supported expression forms:
+
+- strings, numbers, booleans, symbols
+- binary `+`, `-`, `*`, `/`
+
+## Direction
+
+The next useful step is a typed backend beside Jerboa's existing Rust and LLVM
+emitters, so pure typed Jerboa functions can be emitted to Kotlin and called
+from generated Android screens.
diff --git a/docs/roadmap.md b/docs/roadmap.md
new file mode 100644
index 0000000..78bf088
--- /dev/null
+++ b/docs/roadmap.md
@@ -0,0 +1,28 @@
+# Roadmap
+
+## MVP
+
+- Generate a Kotlin/Android Gradle project from a Jerboa `.ss` app spec.
+- Support scalar state, text, buttons, rows, columns, and spacers.
+- Keep generated Android code dependency-light and readable.
+
+## Near Term
+
+- Add navigation and multiple screens.
+- Add text input, lists, checkboxes, and simple resources.
+- Add generated tests for the emitted Kotlin files.
+- Add an Android SDK CI job once the SourceHut image setup is settled.
+
+## Compiler Track
+
+- Reuse the existing Typed Jerboa parser/checker model.
+- Add a Kotlin emitter for pure logic functions.
+- Support records, variants, `Option`, `Result`, `match`, `if`, `let`, and
+  simple numeric/string/byte operations.
+- Let app specs call typed functions from generated UI actions.
+
+## Deferred
+
+- Full dynamic Jerboa to Kotlin transpilation.
+- Embedding the full Chez/Jerboa runtime in APKs.
+- WASM-hosted Jerboa logic inside Android apps.
diff --git a/examples/counter/app.ss b/examples/counter/app.ss
new file mode 100644
index 0000000..1c61760
--- /dev/null
+++ b/examples/counter/app.ss
@@ -0,0 +1,17 @@
+(import (jerboa prelude))
+
+(def app
+  '(android-app
+     (id "org.jerboa.counter")
+     (name "Jerboa Counter")
+     (compile-sdk 36)
+     (min-sdk 26)
+     (target-sdk 36)
+     (screen Main
+       (state count 0)
+       (column
+         (text "Count: " count)
+         (spacer 16)
+         (button "Increment" (set count (+ count 1)))
+         (spacer 8)
+         (button "Reset" (set count 0))))))
diff --git a/jandroid.ss b/jandroid.ss
new file mode 100644
index 0000000..8e8badc
--- /dev/null
+++ b/jandroid.ss
@@ -0,0 +1,393 @@
+(import (jerboa prelude)
+        (only (chezscheme) mkdir file-directory?))
+
+(def (usage)
+  (displayln "Usage:")
+  (displayln "  jerboa jandroid.ss generate <app.ss> <out-dir>")
+  (displayln "  jerboa jandroid.ss --help"))
+
+(def (read-all-forms path)
+  (with-input-from-string (read-file-string path)
+    (lambda ()
+      (let loop ((forms '()))
+        (let ((form (read)))
+          (if (eof-object? form)
+              (reverse forms)
+              (loop (cons form forms))))))))
+
+(def (unquote-form form)
+  (if (and (pair? form) (eq? (car form) 'quote) (pair? (cdr form)))
+      (cadr form)
+      form))
+
+(def (find-app forms)
+  (or (for/or ((form forms))
+        (if (and (pair? form)
+                 (eq? (car form) 'def)
+                 (pair? (cdr form))
+                 (eq? (cadr form) 'app)
+                 (pair? (cddr form)))
+            (unquote-form (caddr form))
+            #f))
+      (error 'find-app "expected `(def app '(...))` in app spec")))
+
+(def (ensure-directory path)
+  (unless (or (string=? path "")
+              (string=? path ".")
+              (file-directory? path))
+    (let ((parent (path-directory path)))
+      (when (and (not (string=? parent ""))
+                 (not (string=? parent "."))
+                 (not (string=? parent path)))
+        (ensure-directory parent)))
+    (try (mkdir path)
+         (catch (exn) (void)))))
+
+(def (entries spec)
+  (if (and (pair? spec) (eq? (car spec) 'android-app))
+      (cdr spec)
+      (error 'entries "expected android-app spec" spec)))
+
+(def (entry-value items key default)
+  (let loop ((xs items))
+    (cond
+      ((null? xs) default)
+      ((and (pair? (car xs))
+            (eq? (caar xs) key)
+            (pair? (cdar xs)))
+       (cadar xs))
+      (else (loop (cdr xs))))))
+
+(def (find-entry items key)
+  (let loop ((xs items))
+    (cond
+      ((null? xs) #f)
+      ((and (pair? (car xs)) (eq? (caar xs) key)) (car xs))
+      (else (loop (cdr xs))))))
+
+(def (app-id spec)
+  (entry-value (entries spec) 'id "org.jerboa.generated"))
+
+(def (app-name spec)
+  (entry-value (entries spec) 'name "Jerboa Android"))
+
+(def (compile-sdk spec)
+  (entry-value (entries spec) 'compile-sdk 36))
+
+(def (target-sdk spec)
+  (entry-value (entries spec) 'target-sdk (compile-sdk spec)))
+
+(def (min-sdk spec)
+  (entry-value (entries spec) 'min-sdk 26))
+
+(def (main-screen spec)
+  (or (find-entry (entries spec) 'screen)
+      (error 'main-screen "expected a screen entry")))
+
+(def (screen-name screen)
+  (if (and (pair? (cdr screen)) (symbol? (cadr screen)))
+      (cadr screen)
+      (error 'screen-name "screen must have a symbol name" screen)))
+
+(def (screen-items screen)
+  (cddr screen))
+
+(def (state-entry? item)
+  (and (pair? item) (eq? (car item) 'state)))
+
+(def (screen-states screen)
+  (filter state-entry? (screen-items screen)))
+
+(def (screen-layout screen)
+  (or (for/or ((item (screen-items screen)))
+        (if (state-entry? item) #f item))
+      '(column)))
+
+(def (kotlin-ident sym)
+  (symbol->string sym))
+
+(def (kotlin-string s)
+  (with-output-to-string
+    (lambda ()
+      (display "\"")
+      (for ((ch (in-string s)))
+        (cond
+          ((char=? ch #\") (display "\\\""))
+          ((char=? ch #\\) (display "\\\\"))
+          ((char=? ch #\newline) (display "\\n"))
+          ((char=? ch #\tab) (display "\\t"))
+          (else (display ch))))
+      (display "\""))))
+
+(def (kotlin-value value)
+  (cond
+    ((string? value) (kotlin-string value))
+    ((symbol? value) (kotlin-ident value))
+    ((boolean? value) (if value "true" "false"))
+    ((number? value) (format "~a" value))
+    (else (error 'kotlin-value "unsupported literal" value))))
+
+(def (kotlin-expr expr)
+  (cond
+    ((or (string? expr) (symbol? expr) (boolean? expr) (number? expr))
+     (kotlin-value expr))
+    ((and (pair? expr) (memq (car expr) '(+ - * /)) (= (length expr) 3))
+     (string-append "("
+                    (kotlin-expr (cadr expr))
+                    " "
+                    (symbol->string (car expr))
+                    " "
+                    (kotlin-expr (caddr expr))
+                    ")"))
+    (else (error 'kotlin-expr "unsupported expression" expr))))
+
+(def (kotlin-action action)
+  (cond
+    ((and (pair? action)
+          (eq? (car action) 'set)
+          (= (length action) 3)
+          (symbol? (cadr action)))
+     (string-append (kotlin-ident (cadr action)) " = " (kotlin-expr (caddr action))))
+    (else (error 'kotlin-action "unsupported action" action))))
+
+(def (text-expr parts)
+  (if (null? parts)
+      "\"\""
+      (string-join
+        (map (lambda (part)
+               (if (symbol? part)
+                   (string-append (kotlin-ident part) ".toString()")
+                   (kotlin-expr part)))
+             parts)
+        " + ")))
+
+(def (indent n)
+  (make-string n #\space))
+
+(def (line n text)
+  (string-append (indent n) text "\n"))
+
+(def (add-view-prefix target)
+  (if (string-empty? target) "addView" (string-append target ".addView")))
+
+(def (emit-view item level target context)
+  (cond
+    ((and (pair? item) (eq? (car item) 'column))
+     (string-append
+       (line level (string-append (add-view-prefix target) "(LinearLayout(" context ").apply {"))
+       (line (+ level 4) "orientation = LinearLayout.VERTICAL")
+       (line (+ level 4) "gravity = Gravity.CENTER")
+       (line (+ level 4) "setPadding(dp(24), dp(24), dp(24), dp(24))")
+       (apply string-append
+              (map (lambda (child)
+                     (emit-view child (+ level 4) "" "this@MainActivity"))
+                   (cdr item)))
+       (line level "})")))
+    ((and (pair? item) (eq? (car item) 'row))
+     (string-append
+       (line level (string-append (add-view-prefix target) "(LinearLayout(" context ").apply {"))
+       (line (+ level 4) "orientation = LinearLayout.HORIZONTAL")
+       (line (+ level 4) "gravity = Gravity.CENTER")
+       (apply string-append
+              (map (lambda (child)
+                     (emit-view child (+ level 4) "" "this@MainActivity"))
+                   (cdr item)))
+       (line level "})")))
+    ((and (pair? item) (eq? (car item) 'text))
+     (string-append
+       (line level (string-append (add-view-prefix target) "(TextView(" context ").apply {"))
+       (line (+ level 4) (string-append "text = " (text-expr (cdr item))))
+       (line (+ level 4) "textSize = 28f")
+       (line (+ level 4) "gravity = Gravity.CENTER")
+       (line level "})")))
+    ((and (pair? item) (eq? (car item) 'button) (>= (length item) 2))
+     (let ((label (cadr item))
+           (actions (cddr item)))
+       (string-append
+         (line level (string-append (add-view-prefix target) "(Button(" context ").apply {"))
+         (line (+ level 4) (string-append "text = " (kotlin-expr label)))
+         (line (+ level 4) "setOnClickListener {")
+         (apply string-append
+                (map (lambda (action)
+                       (line (+ level 8) (string-append (kotlin-action action) "; render()")))
+                     actions))
+         (line (+ level 4) "}")
+         (line level "})"))))
+    ((and (pair? item) (eq? (car item) 'spacer) (= (length item) 2))
+     (string-append
+       (line level (string-append (add-view-prefix target) "(Space(" context ").apply {"))
+       (line (+ level 4) (string-append "layoutParams = LinearLayout.LayoutParams(1, dp("
+                                  (kotlin-expr (cadr item))
+                                  "))"))
+       (line level "})")))
+    (else (error 'emit-view "unsupported view item" item))))
+
+(def (state-name state)
+  (if (and (= (length state) 3) (symbol? (cadr state)))
+      (cadr state)
+      (error 'state-name "state entries are `(state name initial-value)`" state)))
+
+(def (state-initial state)
+  (caddr state))
+
+(def (emit-state state)
+  (string-append "    private var "
+                 (kotlin-ident (state-name state))
+                 " = "
+                 (kotlin-expr (state-initial state))
+                 "\n"))
+
+(def (package-path package-name)
+  (let loop ((parts (string-split package-name #\.))
+             (path ""))
+    (cond
+      ((null? parts) path)
+      ((string-empty? path) (loop (cdr parts) (car parts)))
+      (else (loop (cdr parts) (path-join path (car parts)))))))
+
+(def (main-activity-kt spec)
+  (let* ((package-name (app-id spec))
+         (screen (main-screen spec))
+         (states (screen-states screen))
+         (layout (screen-layout screen)))
+    (string-append
+      "package " package-name "\n\n"
+      "import android.app.Activity\n"
+      "import android.os.Bundle\n"
+      "import android.view.Gravity\n"
+      "import android.widget.Button\n"
+      "import android.widget.LinearLayout\n"
+      "import android.widget.Space\n"
+      "import android.widget.TextView\n\n"
+      "class MainActivity : Activity() {\n"
+      (apply string-append (map emit-state states))
+      "\n"
+      "    override fun onCreate(savedInstanceState: Bundle?) {\n"
+      "        super.onCreate(savedInstanceState)\n"
+      "        render()\n"
+      "    }\n\n"
+      "    private fun render() {\n"
+      "        val root = LinearLayout(this)\n"
+      "        root.orientation = LinearLayout.VERTICAL\n"
+      "        root.gravity = Gravity.CENTER\n"
+      "        root.setPadding(dp(24), dp(24), dp(24), dp(24))\n"
+      (emit-view layout 8 "root" "this")
+      "        setContentView(root)\n"
+      "    }\n\n"
+      "    private fun dp(value: Int): Int {\n"
+      "        return (value * resources.displayMetrics.density).toInt()\n"
+      "    }\n"
+      "}\n")))
+
+(def (settings-gradle spec)
+  (string-append
+    "pluginManagement {\n"
+    "    repositories {\n"
+    "        google()\n"
+    "        mavenCentral()\n"
+    "        gradlePluginPortal()\n"
+    "    }\n"
+    "}\n\n"
+    "dependencyResolutionManagement {\n"
+    "    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)\n"
+    "    repositories {\n"
+    "        google()\n"
+    "        mavenCentral()\n"
+    "    }\n"
+    "}\n\n"
+    "rootProject.name = " (kotlin-string (app-name spec)) "\n"
+    "include(\":app\")\n"))
+
+(def (root-build-gradle)
+  (string-append
+    "plugins {\n"
+    "    id(\"com.android.application\") version \"9.2.0\" apply false\n"
+    "}\n"))
+
+(def (app-build-gradle spec)
+  (string-append
+    "plugins {\n"
+    "    id(\"com.android.application\")\n"
+    "}\n\n"
+    "android {\n"
+    "    namespace = " (kotlin-string (app-id spec)) "\n"
+    "    compileSdk = " (format "~a" (compile-sdk spec)) "\n\n"
+    "    defaultConfig {\n"
+    "        applicationId = " (kotlin-string (app-id spec)) "\n"
+    "        minSdk = " (format "~a" (min-sdk spec)) "\n"
+    "        targetSdk = " (format "~a" (target-sdk spec)) "\n"
+    "        versionCode = 1\n"
+    "        versionName = \"0.1.0\"\n"
+    "    }\n"
+    "}\n"))
+
+(def (manifest-xml spec)
+  (string-append
+    "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
+    "<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n"
+    "    <application\n"
+    "        android:allowBackup=\"false\"\n"
+    "        android:label=\"" (app-name spec) "\"\n"
+    "        android:theme=\"@style/AppTheme\">\n"
+    "        <activity\n"
+    "            android:name=\".MainActivity\"\n"
+    "            android:exported=\"true\">\n"
+    "            <intent-filter>\n"
+    "                <action android:name=\"android.intent.action.MAIN\" />\n"
+    "                <category android:name=\"android.intent.category.LAUNCHER\" />\n"
+    "            </intent-filter>\n"
+    "        </activity>\n"
+    "    </application>\n"
+    "</manifest>\n"))
+
+(def (styles-xml)
+  (string-append
+    "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
+    "<resources>\n"
+    "    <style name=\"AppTheme\" parent=\"android:style/Theme.Material.Light.NoActionBar\">\n"
+    "        <item name=\"android:windowActionBar\">false</item>\n"
+    "        <item name=\"android:windowNoTitle\">true</item>\n"
+    "    </style>\n"
+    "</resources>\n"))
+
+(def (write-generated-file path content)
+  (write-file-string path content)
+  (displayln "wrote " path))
+
+(def (generate spec out-dir)
+  (let* ((package-name (app-id spec))
+         (java-dir (path-join out-dir "app" "src" "main" "java" (package-path package-name)))
+         (res-dir (path-join out-dir "app" "src" "main" "res" "values"))
+         (main-dir (path-join out-dir "app" "src" "main")))
+    (ensure-directory java-dir)
+    (ensure-directory res-dir)
+    (write-generated-file (path-join out-dir "settings.gradle.kts") (settings-gradle spec))
+    (write-generated-file (path-join out-dir "build.gradle.kts") (root-build-gradle))
+    (write-generated-file (path-join out-dir "app" "build.gradle.kts") (app-build-gradle spec))
+    (write-generated-file (path-join main-dir "AndroidManifest.xml") (manifest-xml spec))
+    (write-generated-file (path-join res-dir "styles.xml") (styles-xml))
+    (write-generated-file (path-join java-dir "MainActivity.kt") (main-activity-kt spec))))
+
+(def (cli-args raw)
+  (cond
+    ((null? raw) '())
+    ((or (string=? (car raw) "generate")
+         (string=? (car raw) "--help"))
+     raw)
+    (else (cli-args (cdr raw)))))
+
+(def (main)
+  (let ((args (cli-args (command-line))))
+    (cond
+      ((or (null? args) (string=? (car args) "--help"))
+       (usage))
+      ((and (= (length args) 3) (string=? (car args) "generate"))
+       (let* ((spec-path (cadr args))
+              (out-dir (caddr args))
+              (spec (find-app (read-all-forms spec-path))))
+         (generate spec out-dir)))
+      (else
+       (usage)
+       (error 'jandroid "invalid arguments" args)))))
+
+(main)