Initial Jerboa Android generator
ober
11723843296d93c0a6d5b9ee43044aea9e7a0ab4
new file mode 100644 --- /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 new file mode 100644 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build/ +.gradle/ +local.properties +*.apk +*.aab new file mode 100644 --- /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 new file mode 100644 --- /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. new file mode 100644 --- /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. new file mode 100644 --- /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)))))) new file mode 100644 --- /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)