Support typed Kotlin imports for SSD records

ober

ee505be9053a52fa7bfa07270b2395230407b6b1

diff --git a/README.md b/README.md
index 3842fb0..a1eed71 100644
--- a/README.md
+++ b/README.md
@@ -122,6 +122,17 @@ app spec.
 `typed-kotlin-file` emits a normal Typed Jerboa `typed-library` through the
 upstream `(jerboa typed kotlin)` backend. Use it for new generated Kotlin
 instead of `kotlin-file`, `kotlin-file-lines`, or `kotlin-source-dir`.
+When generated records need JVM/Android types, add structured imports before the
+library form:
+
+```scheme
+(typed-kotlin-file "com/example/Pick.kt"
+  (kotlin-imports (android net Uri))
+  (typed-library (com example)
+    (export make-Pick Pick? Pick-uri)
+    (type Uri)
+    (record Pick ((uri : Uri)))))
+```
 
 `client` expands a named generator-owned template from `templates/<name>.ss`.
 This is the preferred bridge for larger apps while the higher-level Android DSL
diff --git a/jandroid.ss b/jandroid.ss
index a655563..12abfe0 100644
--- a/jandroid.ss
+++ b/jandroid.ss
@@ -1,6 +1,8 @@
 (import (jerboa prelude)
         (only (chezscheme) file-directory?)
         (jerboa typed kotlin)
+        (jerboa typed kotlin ast)
+        (jerboa typed parser)
         (jandroid secure-output))
 
 (def *max-copy-files* 10000)
@@ -792,23 +794,67 @@
          (error 'kotlin-file "expected `(kotlin-file path content)` or `(kotlin-file-lines path (line ...))`" file-form))))
     (kotlin-files spec)))
 
+(def (symbol-list? value)
+  (and (list? value)
+       (not (null? value))
+       (every symbol? value)))
+
+(def (typed-kotlin-imports-form? value)
+  (and (pair? value)
+       (eq? (car value) 'kotlin-imports)))
+
+(def (typed-kotlin-imports import-form)
+  (unless (typed-kotlin-imports-form? import-form)
+    (error 'typed-kotlin-file "expected `(kotlin-imports ((package path) ...))`"
+           import-form))
+  (map
+    (lambda (path)
+      (unless (symbol-list? path)
+        (error 'typed-kotlin-file "Kotlin import path must be a non-empty symbol list"
+               path))
+      (make-kt-import path #f))
+    (cdr import-form)))
+
+(def (typed-kotlin-file-parts file-form)
+  (cond
+    [(and (= (length file-form) 3)
+          (string? (cadr file-form))
+          (pair? (caddr file-form))
+          (eq? (car (caddr file-form)) 'typed-library))
+     (values (cadr file-form) '() (caddr file-form))]
+    [(and (= (length file-form) 4)
+          (string? (cadr file-form))
+          (typed-kotlin-imports-form? (caddr file-form))
+          (pair? (cadddr file-form))
+          (eq? (car (cadddr file-form)) 'typed-library))
+     (values (cadr file-form)
+             (typed-kotlin-imports (caddr file-form))
+             (cadddr file-form))]
+    [else
+     (error 'typed-kotlin-file
+            "expected `(typed-kotlin-file path (typed-library ...))` or `(typed-kotlin-file path (kotlin-imports (...)) (typed-library ...))`"
+            file-form)]))
+
+(def (typed-library-form->kotlin-string/imports library-form imports)
+  (let* ([file (typed-module->kotlin-file (parse-typed-library library-form))]
+         [merged (make-kt-file
+                   (kt-file-package file)
+                   imports
+                   (kt-file-declarations file))])
+    (kotlin-file->string merged)))
+
 (def (write-typed-kotlin-files spec root)
   (for-each
     (lambda (file-form)
-      (unless (and (= (length file-form) 3)
-                   (string? (cadr file-form))
-                   (pair? (caddr file-form))
-                   (eq? (car (caddr file-form)) 'typed-library))
-        (error 'typed-kotlin-file
-               "expected `(typed-kotlin-file path (typed-library ...))`"
-               file-form))
-      (unless (string=? (path-extension (cadr file-form)) ".kt")
-        (error 'typed-kotlin-file "generated Kotlin path must end in .kt"
-               (cadr file-form)))
-      (write-generated-file
-        root
-        (path-join "app" "src" "main" "java" (cadr file-form))
-        (typed-library-form->kotlin-string (caddr file-form))))
+      (let-values ([(path imports library-form)
+                    (typed-kotlin-file-parts file-form)])
+        (unless (string=? (path-extension path) ".kt")
+          (error 'typed-kotlin-file "generated Kotlin path must end in .kt"
+                 path))
+        (write-generated-file
+          root
+          (path-join "app" "src" "main" "java" path)
+          (typed-library-form->kotlin-string/imports library-form imports))))
     (typed-kotlin-files spec)))
 
 (def (generated-main-activity? spec relative)
diff --git a/templates/ssd-review.ss b/templates/ssd-review.ss
index 36c7c1a..a91baf4 100644
--- a/templates/ssd-review.ss
+++ b/templates/ssd-review.ss
@@ -136,6 +136,49 @@
           ((mut files : Int32)
            (mut bytes : Int)))))
 
+    (typed-kotlin-file "com/sfb/ssdreview/SsdPdfChoice.kt"
+      (kotlin-imports (android net Uri))
+      (typed-library (com sfb ssdreview)
+        (export make-SsdPdfChoice SsdPdfChoice?
+                SsdPdfChoice-label SsdPdfChoice-uri)
+        (type Uri)
+        (record SsdPdfChoice
+          ((label : String)
+           (uri : Uri)))))
+
+    (typed-kotlin-file "com/sfb/ssdreview/TruthStoreRecords.kt"
+      (kotlin-imports (java io File) (java net URL))
+      (typed-library (com sfb ssdreview)
+        (export make-RemoteConfig RemoteConfig?
+                RemoteConfig-apiUrl RemoteConfig-spkiSha256 RemoteConfig-bearerToken
+                make-StagedZipEntry StagedZipEntry?
+                StagedZipEntry-name StagedZipEntry-file StagedZipEntry-modified
+                make-TruthIndexEntry TruthIndexEntry?
+                TruthIndexEntry-file TruthIndexEntry-sourceKey
+                TruthIndexEntry-sourceName TruthIndexEntry-canonicalSourceName
+                TruthIndexEntry-page TruthIndexEntry-dpi TruthIndexEntry-groupCount
+                TruthIndexEntry-truthTime)
+        (type File)
+        (type URL)
+        (type Int32)
+        (record RemoteConfig
+          ((apiUrl : URL)
+           (spkiSha256 : (Nullable Bytes))
+           (bearerToken : String)))
+        (record StagedZipEntry
+          ((name : String)
+           (file : File)
+           (modified : Int)))
+        (record TruthIndexEntry
+          ((file : File)
+           (sourceKey : String)
+           (sourceName : String)
+           (canonicalSourceName : String)
+           (page : Int32)
+           (dpi : Int32)
+           (groupCount : Int32)
+           (truthTime : Int)))))
+
     (typed-kotlin-file "com/sfb/ssdreview/Guess.kt"
       (typed-library (com sfb ssdreview)
         (export make-Guess Guess? Guess-label Guess-boxType Guess-reason)
@@ -2125,8 +2168,6 @@
        "        private const val CLIENT_LOG_CRASH_TIMEOUT_MS = 1500"
        "        private const val CLIENT_LOG_STACK_CHARS = 12000"
        "    }"
-       ""
-       "    private data class SsdPdfChoice(val label: String, val uri: Uri)"
        "}"
        ))
     (kotlin-file-lines "com/sfb/ssdreview/Models.kt"
@@ -3614,25 +3655,6 @@
        "        private const val MAX_AUTH_TOKEN_CHARS = 4096"
        "    }"
        ""
-       "    private data class RemoteConfig("
-       "        val apiUrl: URL,"
-       "        val spkiSha256: ByteArray?,"
-       "        val bearerToken: String"
-       "    )"
-       ""
-       "    private data class StagedZipEntry(val name: String, val file: File, val modified: Long)"
-       ""
-       "    private data class TruthIndexEntry("
-       "        val file: File,"
-       "        val sourceKey: String,"
-       "        val sourceName: String,"
-       "        val canonicalSourceName: String,"
-       "        val page: Int,"
-       "        val dpi: Int,"
-       "        val groupCount: Int,"
-       "        val truthTime: Long"
-       "    )"
-       ""
        "    private val root = File(context.filesDir, \"ssd_review\")"
        "    private val groundTruthDir = File(root, \"ground_truth\")"
        "    private val learnedDir = File(root, \"learned\")"
@@ -3674,7 +3696,7 @@
        "                    }"
        "                }"
        "        }"
-       "        return best?.let { JSONObject(readLocalText(it.file)) }"
+       "        return best?.let { JSONObject(readLocalText(it._file)) }"
        "    }"
        ""
        "    @Synchronized"
@@ -3700,7 +3722,7 @@
        "            val source = truth.optJSONObject(\"source\") ?: JSONObject()"
        "            val sourceName = sourceBaseName(source.optString(\"name\", source.optString(\"path\")))"
        "            TruthIndexEntry("
-       "                file = file,"
+       "                _file = file,"
        "                sourceKey = truth.optString(\"source_key\"),"
        "                sourceName = sourceName,"
        "                canonicalSourceName = canonicalSourceName(sourceName),"
@@ -4313,7 +4335,7 @@
        ""
        "    private fun installStagedZipEntry(entry: StagedZipEntry): Boolean {"
        "        val destination = containedImportPath(entry.name)"
-       "        val bytes = entry.file.inputStream().use { readBoundedBytes(it, MAX_ZIP_ENTRY_BYTES) }"
+       "        val bytes = entry._file.inputStream().use { readBoundedBytes(it, MAX_ZIP_ENTRY_BYTES) }"
        "        return if (entry.name.endsWith(\".jsonl\")) {"
        "            val before = if (destination.exists()) readLocalText(destination) else \"\""
        "            mergeJsonLines(destination, bytes.decodeToString())"