diff --git a/README.md b/README.md index 6e04a418..023c87d8 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego | [`plugins/Get-AI-Models/`](plugins/Get-AI-Models/) | Downloads small language models for on-device AI addons. | | [`plugins/Icons-Repository/`](plugins/Icons-Repository/) | Adds vector icons to a project from inside the editor. | | [`plugins/Jetpack-Compose-Preview/`](plugins/Jetpack-Compose-Preview/) | Renders Compose preview functions on the device. | +| [`plugins/JVM-Tools/`](plugins/JVM-Tools/) | Runs Java and Kotlin programs, with a main-class picker and program arguments. | | [`plugins/Keystore-Generator/`](plugins/Keystore-Generator/) | Creates and manages app signing keystores on the device. | | [`plugins/Layout-Editor/`](plugins/Layout-Editor/) | Edits Android XML layouts by dragging views. | | [`plugins/Markdown-Previewer/`](plugins/Markdown-Previewer/) | Shows a live preview of Markdown and HTML files. | diff --git a/plugins/JVM-Tools/.gitignore b/plugins/JVM-Tools/.gitignore new file mode 100644 index 00000000..6af499b7 --- /dev/null +++ b/plugins/JVM-Tools/.gitignore @@ -0,0 +1,29 @@ +# Gradle +.gradle/ +build/ +gradle-app.setting +!gradle-wrapper.jar +.gradletasknamecache + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.project +.classpath +.settings/ +.kotlin/ + +# Local configuration +local.properties + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Test outputs +test-results/ diff --git a/plugins/JVM-Tools/README.md b/plugins/JVM-Tools/README.md new file mode 100644 index 00000000..7c880657 --- /dev/null +++ b/plugins/JVM-Tools/README.md @@ -0,0 +1,43 @@ +# JVM Tools + +Runs Java and Kotlin programs in Code on the Go, the way a desktop IDE does: choose the main class, +give it program arguments, and tap Run. + +## What it adds + +In a JVM project, a folder of `.java` and `.kt` files with no Gradle build, the editor toolbar gets: + +- **Run** - compiles every Java and Kotlin file in the project on the device and runs the chosen main + class. Compiler errors and the program's output stream into Build Output, and the button becomes + **Cancel Run** while the program runs. +- **Run configuration** - lists every class with a `main` method and takes the program arguments. + The choice is saved for each project. + +The Gradle actions (Quick Run, Quick Build, Sync, Debug, Run Tasks, Launch App) are hidden in a JVM +project. Android projects are left alone. + +The **Java/Kotlin Program** template on the New Project screen creates a two-class program in +`src/`, in the language you choose. + +## How it runs code + +Nothing is downloaded. Java is compiled with the JDK that ships with Code on the Go, and Kotlin with +the `kotlin-compiler-embeddable` already in the IDE's local Maven repository. Classes go to a folder +under `$PREFIX/tmp/jvm-tools`, not into the project. + +## Limits + +- Programs cannot read keyboard input; pass values as program arguments. +- Only the Java class library and the Kotlin standard library are on the classpath. +- Gradle projects are not handled. + +## Build + +```sh +cd plugins/JVM-Tools +../../gradlew assemblePlugin # build/plugin/jvm-tools.cgp +../../gradlew assemblePluginDebug # build/plugin/jvm-tools-debug.cgp +../../gradlew testDebugUnitTest +``` + +Supports Code on the Go 26.37 through 26.99. diff --git a/plugins/JVM-Tools/addon.json b/plugins/JVM-Tools/addon.json new file mode 100644 index 00000000..730d9f3c --- /dev/null +++ b/plugins/JVM-Tools/addon.json @@ -0,0 +1,15 @@ +{ + "summary": "Runs Java and Kotlin programs, with a main-class picker and program arguments.", + "description": "Compiles a folder of Java and Kotlin files on the device and runs the main class you choose, with the arguments you give it. Output appears in Build Output. Adds a Java/Kotlin Program template.", + "tags": [ + "java", + "kotlin", + "templates" + ], + "origin": "appdevforall", + "license": "AGPL-3.0-or-later", + "author": { + "name": "App Dev For All", + "url": "https://www.appdevforall.org" + } +} diff --git a/plugins/JVM-Tools/build.gradle.kts b/plugins/JVM-Tools/build.gradle.kts new file mode 100644 index 00000000..d9cd4185 --- /dev/null +++ b/plugins/JVM-Tools/build.gradle.kts @@ -0,0 +1,75 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.application") + id("com.itsaky.androidide.plugins.build") +} + +pluginBuilder { + pluginName = "jvm-tools" +} + +android { + namespace = "com.appdevforall.jvm.plugin" + compileSdk = 36 + + defaultConfig { + applicationId = "com.appdevforall.jvm.plugin" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + packaging { + resources { + excludes += setOf( + "META-INF/versions/9/OSGI-INF/MANIFEST.MF", + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt" + ) + } + } + + testOptions { + unitTests.isReturnDefaultValues = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + compileOnly(files("../../libs/plugin-api.jar")) + compileOnly("com.google.android.material:material:1.12.0") + + implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.21") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + testImplementation("junit:junit:4.13.2") +} + +tasks.matching { + it.name.contains("checkDebugAarMetadata") || + it.name.contains("checkReleaseAarMetadata") +}.configureEach { + enabled = false +} diff --git a/plugins/JVM-Tools/gradle.properties b/plugins/JVM-Tools/gradle.properties new file mode 100644 index 00000000..9ba44411 --- /dev/null +++ b/plugins/JVM-Tools/gradle.properties @@ -0,0 +1,4 @@ +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 diff --git a/plugins/JVM-Tools/jvm-tools.html b/plugins/JVM-Tools/jvm-tools.html new file mode 100644 index 00000000..5e8fac90 --- /dev/null +++ b/plugins/JVM-Tools/jvm-tools.html @@ -0,0 +1,227 @@ + + + + + +JVM Tools + + + + + +
+

JVM Tools

+

Compile and run Java and Kotlin programs on the device, with a main-class picker and program arguments

+
Version 1.0.0 · Author: App Dev For All · Package: com.appdevforall.jvm.plugin
+
+ + + +
+

1. Executive Overview

+

+ JVM Tools runs Java and Kotlin programs the way a desktop IDE does: choose the class whose + main should start, give it program arguments, and tap Run. The project is compiled + on the device with the JDK and Kotlin compiler that Code on the Go already ships, and the output + streams into Build Output. Nothing is downloaded. +

+

+ It applies to JVM projects only: a folder of .java and .kt files with no + Gradle build. Android projects keep their Gradle toolbar untouched. +

+
+ +
+

2. Core Functionality

+ + + + + +
FeatureWhat it does
RunCompiles every Java and Kotlin file in the project and runs the chosen main class with its arguments. Becomes Cancel Run while the program runs.
Run configurationLists every class with a main method and takes the program arguments. Saved per project.
Java/Kotlin ProgramA New Project template with Main and Greeter classes in Java or Kotlin.
+
+ +
+

3. Technical Architecture

+ +

Domain gating

+

+ JvmDomain treats the open project as JVM when its root has no Gradle build file and a + .java or .kt file sits within four folders of it. Every contribution + below is gated on that check, so the plugin is invisible in Android projects. +

+ +

Finding main classes

+

+ MainClassFinder reads the sources with comments removed and reports Java classes with a + static void main(String[]), Kotlin files with a top-level fun main (as the + FileNameKt facade, or the @file:JvmName), and Kotlin objects or companion + objects with a @JvmStatic main. Names are qualified with the file's package. +

+ +

Compiling and running

+

Run is a PluginBuildAction whose shell command:

+
    +
  1. lists the project's sources, skipping build/ and dot folders;
  2. +
  3. compiles all sources with K2JVMCompiler from kotlin-compiler-embeddable in the IDE's local Maven repository, when any .kt file exists (Java files are passed too, so Kotlin can see Java classes);
  4. +
  5. compiles the .java files with the JDK's javac against the Kotlin output;
  6. +
  7. replaces itself with java -cp classes[:kotlin-stdlib] MainClass args..., so cancelling the action stops the program.
  8. +
+

+ Classes are written to a per-project folder under $PREFIX/tmp/jvm-tools, never into + the project. The toolchain paths come from IdeEnvironmentService.getTmpDirectory(), + whose parent is $PREFIX. +

+ +

Hiding the Gradle actions

+

+ toolbarActionsToHide() returns ToolbarActionIds.BUILD_HIDEABLE, and + getHiddenToolbarActionIds() adds Quick Build, which is not in that set. +

+
+ +
+

4. Integration Points

+ + + + + + + + +
APIUsed for
BuildActionExtensionThe Run action and the pre-run save of unsaved Java and Kotlin buffers
UIExtensionThe Run configuration toolbar button and hiding Quick Build
DocumentationExtensionLong-press tooltips and the in-app help page
IdeTemplateServiceThe Java/Kotlin Program template
IdeEditorServicegetModifiedFiles, getFileContent and saveFile before a run
PluginWindowsShowing the Run configuration dialog, a Material dialog built on the IDE activity
+

+ Permissions: filesystem.read, filesystem.write, + system.commands, project.structure. Supports Code on the Go 26.37 through + 26.99; 26.37 is the first release with PluginWindows, which shows the Run configuration + dialog. +

+
+ +
+

5. Usage

+
    +
  1. Install the plugin from the Plugin Manager and restart Code on the Go.
  2. +
  3. Create a project with Java/Kotlin Program on the New Project screen, or open a folder of .java and .kt files.
  4. +
  5. Tap Run. The project compiles and the output appears in Build Output.
  6. +
  7. To run another class or pass arguments, tap Run configuration, choose the main class, enter the arguments, and tap Save. Run uses that choice from then on.
  8. +
+

Build the plugin itself with ../../gradlew assemblePlugin from plugins/JVM-Tools.

+
+ +
+

6. Key Benefits

+ +
+ +
+

7. Limits

+ +
+ + + + + diff --git a/plugins/JVM-Tools/proguard-rules.pro b/plugins/JVM-Tools/proguard-rules.pro new file mode 100644 index 00000000..012c823b --- /dev/null +++ b/plugins/JVM-Tools/proguard-rules.pro @@ -0,0 +1 @@ +-keep class com.appdevforall.jvm.plugin.** { *; } diff --git a/plugins/JVM-Tools/settings.gradle.kts b/plugins/JVM-Tools/settings.gradle.kts new file mode 100644 index 00000000..7a049907 --- /dev/null +++ b/plugins/JVM-Tools/settings.gradle.kts @@ -0,0 +1,30 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath(files("../../libs/plugin-api.jar")) + classpath(files("../../libs/gradle-plugin.jar")) + classpath("com.android.tools.build:gradle:9.3.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.21") + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "jvm-tools" diff --git a/plugins/JVM-Tools/src/main/AndroidManifest.xml b/plugins/JVM-Tools/src/main/AndroidManifest.xml new file mode 100644 index 00000000..c3d241be --- /dev/null +++ b/plugins/JVM-Tools/src/main/AndroidManifest.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/JVM-Tools/src/main/assets/docs/css/walkthrough.css b/plugins/JVM-Tools/src/main/assets/docs/css/walkthrough.css new file mode 100644 index 00000000..ec3bcf7a --- /dev/null +++ b/plugins/JVM-Tools/src/main/assets/docs/css/walkthrough.css @@ -0,0 +1,70 @@ +/* Tier 3 walkthrough styles - kept tiny so the doc loads instantly even + * on slow devices. Dark-mode friendly via CSS color-scheme + media + * query. No external resources / fonts. + */ + +:root { + color-scheme: light dark; + --bg: #ffffff; + --fg: #1b1b1f; + --muted: #5e5e6c; + --accent: #485d92; + --code-bg: #f5f6fa; + --code-fg: #1b1b1f; + --border: #e3e3ea; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #121215; + --fg: #e6e1e5; + --muted: #b8b8c0; + --accent: #b1c5ff; + --code-bg: #1a1a1f; + --code-fg: #e6e1e5; + --border: #303038; + } +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--fg); + line-height: 1.55; + padding: 24px 20px 64px; + max-width: 760px; + margin: 0 auto; +} +h1 { font-size: 1.6rem; margin: 0 0 4px; } +h2 { font-size: 1.2rem; margin: 32px 0 8px; color: var(--accent); } +h3 { font-size: 1.05rem; margin: 24px 0 6px; } +p, li { font-size: 0.95rem; } +.lede { color: var(--muted); margin: 0 0 24px; } +a { color: var(--accent); } +ul, ol { padding-left: 20px; } +hr { border: 0; border-top: 1px solid var(--border); margin: 24px 0; } + +pre { + background: var(--code-bg); + color: var(--code-fg); + padding: 12px 14px; + overflow-x: auto; + border-radius: 6px; + border: 1px solid var(--border); + font-size: 0.85rem; + line-height: 1.45; + font-family: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, monospace; +} +code { font-family: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, monospace; font-size: 0.9em; } +.callout { + background: rgba(72, 93, 146, 0.08); + border-left: 3px solid var(--accent); + padding: 10px 14px; + margin: 16px 0; + border-radius: 4px; +} +.muted { color: var(--muted); font-size: 0.85rem; } +table { border-collapse: collapse; width: 100%; margin: 12px 0; } +th, td { border-bottom: 1px solid var(--border); padding: 8px 10px; text-align: left; font-size: 0.9rem; } +th { color: var(--muted); font-weight: 600; } diff --git a/plugins/JVM-Tools/src/main/assets/docs/index.html b/plugins/JVM-Tools/src/main/assets/docs/index.html new file mode 100644 index 00000000..92c821bf --- /dev/null +++ b/plugins/JVM-Tools/src/main/assets/docs/index.html @@ -0,0 +1,71 @@ + + + + + +JVM Tools - Commands + + + + +

JVM Tools

+

Run Java and Kotlin programs on the device, straight from the editor toolbar.

+ +

JVM Tools adds a project template and two toolbar commands. The commands appear only while a +JVM project is open; in an Android project the toolbar is left alone. Because Gradle plays no part +in a JVM project, the Gradle actions (Quick Run, Quick Build, Sync, Debug, Run Tasks, Launch App) +are hidden while you work in one.

+ +
+

A project counts as JVM when it has no Gradle build file (build.gradle, +build.gradle.kts, settings.gradle, or settings.gradle.kts) +and holds at least one .java or .kt file within four folders of its +root.

+
+ +

The commands

+ +

Run

+

Compiles every .java and .kt file in the project, then starts the main +class chosen in Run configuration with its program arguments. Folders named build and +folders whose names start with a dot are skipped. Kotlin is compiled first, so Java and Kotlin +classes can use each other.

+

When no main class has been chosen, Run starts the main class of the file open in the editor, +or else the first main class in alphabetical order.

+

Compiler errors and the program's output stream into Build Output. While the program runs, +the button becomes Cancel Run; tap it to stop the program. A run is stopped after 30 +minutes. Unsaved changes in open Java and Kotlin files are saved before compiling.

+ +

Run configuration

+

Chooses which main class Run starts and the program arguments it passes. The list holds every +class with a main method:

+ +

Program arguments are split on spaces. Wrap an argument in single or double quotes to keep its +spaces, for example --name "Ada Lovelace". The choice is saved for each project.

+ +

The project template

+

Java/Kotlin Program on the New Project screen creates a program in +src/: a Main class that prints a greeting built by a +Greeter class. Choose Java or Kotlin when you create it, then tap Run.

+ +

What it cannot do

+ + +

Turning it off

+

Disable JVM Tools in the Plugin Manager. The two commands disappear, the Gradle actions come +back for every project, and the template is removed from the New Project screen.

+ + + diff --git a/plugins/JVM-Tools/src/main/assets/icon_day.png b/plugins/JVM-Tools/src/main/assets/icon_day.png new file mode 100644 index 00000000..7dcdcbc2 Binary files /dev/null and b/plugins/JVM-Tools/src/main/assets/icon_day.png differ diff --git a/plugins/JVM-Tools/src/main/assets/icon_night.png b/plugins/JVM-Tools/src/main/assets/icon_night.png new file mode 100644 index 00000000..74d7bf5f Binary files /dev/null and b/plugins/JVM-Tools/src/main/assets/icon_night.png differ diff --git a/plugins/JVM-Tools/src/main/assets/templates/console/Greeter.java.peb b/plugins/JVM-Tools/src/main/assets/templates/console/Greeter.java.peb new file mode 100644 index 00000000..88f00446 --- /dev/null +++ b/plugins/JVM-Tools/src/main/assets/templates/console/Greeter.java.peb @@ -0,0 +1,5 @@ +public class Greeter { + public static String greet(String name) { + return "Hello, " + name + "! This program runs on your device."; + } +} diff --git a/plugins/JVM-Tools/src/main/assets/templates/console/Greeter.kt.peb b/plugins/JVM-Tools/src/main/assets/templates/console/Greeter.kt.peb new file mode 100644 index 00000000..8b6323a0 --- /dev/null +++ b/plugins/JVM-Tools/src/main/assets/templates/console/Greeter.kt.peb @@ -0,0 +1,3 @@ +object Greeter { + fun greet(name: String) = "Hello, $name! This program runs on your device." +} diff --git a/plugins/JVM-Tools/src/main/assets/templates/console/Main.java.peb b/plugins/JVM-Tools/src/main/assets/templates/console/Main.java.peb new file mode 100644 index 00000000..540236e5 --- /dev/null +++ b/plugins/JVM-Tools/src/main/assets/templates/console/Main.java.peb @@ -0,0 +1,6 @@ +public class Main { + public static void main(String[] args) { + String name = args.length > 0 ? String.join(" ", args) : "world"; + System.out.println(Greeter.greet(name)); + } +} diff --git a/plugins/JVM-Tools/src/main/assets/templates/console/Main.kt.peb b/plugins/JVM-Tools/src/main/assets/templates/console/Main.kt.peb new file mode 100644 index 00000000..84e72b38 --- /dev/null +++ b/plugins/JVM-Tools/src/main/assets/templates/console/Main.kt.peb @@ -0,0 +1,4 @@ +fun main(args: Array) { + val name = if (args.isNotEmpty()) args.joinToString(" ") else "world" + println(Greeter.greet(name)) +} diff --git a/plugins/JVM-Tools/src/main/assets/templates/console/README.md b/plugins/JVM-Tools/src/main/assets/templates/console/README.md new file mode 100644 index 00000000..f5ff2d60 --- /dev/null +++ b/plugins/JVM-Tools/src/main/assets/templates/console/README.md @@ -0,0 +1,8 @@ +# Java/Kotlin program + +A command-line program that runs on your device. + +- Tap **Run** to compile the project and run `Main`. The output appears in Build Output. +- Tap **Run configuration** to choose another main class or to pass program arguments, + for example `Ada Lovelace`. +- Add more classes under `src/`. Every Java and Kotlin file in the project is compiled together. diff --git a/plugins/JVM-Tools/src/main/assets/templates/console/thumb.png b/plugins/JVM-Tools/src/main/assets/templates/console/thumb.png new file mode 100644 index 00000000..c781d606 Binary files /dev/null and b/plugins/JVM-Tools/src/main/assets/templates/console/thumb.png differ diff --git a/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/JvmDomain.kt b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/JvmDomain.kt new file mode 100644 index 00000000..ed5fabea --- /dev/null +++ b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/JvmDomain.kt @@ -0,0 +1,32 @@ +package com.appdevforall.jvm.plugin + +import java.io.File + +internal object JvmDomain { + + private val GRADLE_MARKERS = listOf("settings.gradle", "settings.gradle.kts", "build.gradle", "build.gradle.kts") + private const val SCAN_DEPTH = 4 + + @Volatile + private var cachedRoot: String? = null + + @Volatile + private var cachedResult = false + + @Synchronized + fun isJvmProject(root: File?): Boolean { + if (root == null) return false + val path = root.absolutePath + if (path != cachedRoot) { + cachedResult = GRADLE_MARKERS.none { File(root, it).exists() } && hasJvmSource(root, SCAN_DEPTH) + cachedRoot = path + } + return cachedResult + } + + private fun hasJvmSource(dir: File, depth: Int): Boolean { + val children = dir.listFiles() ?: return false + if (children.any { it.isFile && MainClassFinder.isSource(it) }) return true + return depth > 0 && children.any { it.isDirectory && !MainClassFinder.isSkipped(it) && hasJvmSource(it, depth - 1) } + } +} diff --git a/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/JvmToolsDocumentation.kt b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/JvmToolsDocumentation.kt new file mode 100644 index 00000000..be122691 --- /dev/null +++ b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/JvmToolsDocumentation.kt @@ -0,0 +1,57 @@ +package com.appdevforall.jvm.plugin + +import com.itsaky.androidide.plugins.extensions.PluginTooltipButton +import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry + +internal object JvmToolsDocumentation { + + const val CATEGORY = "plugin_${JvmToolsPlugin.PLUGIN_ID}" + const val DOCS_ASSET_PATH = "docs" + + fun entries(): List = listOf( + PluginTooltipEntry( + tag = tagFor(JvmToolsPlugin.ACTION_RUN), + summary = "Compiles every Java and Kotlin file in the project and runs the chosen main class.", + detail = """ +

Compiles all .java and .kt files in the project on the device, + then starts the main class chosen in Run configuration with its program arguments. + With no choice saved, Run starts the main class of the file open in the editor, or the + first one it finds.

+

Compiler errors and the program's output stream into Build Output. While the + program runs, the button becomes Cancel Run; tap it to stop the program. A run is + stopped after 30 minutes.

+

The program cannot read keyboard input: Scanner and + readLine() see the end of input. Pass values as program arguments instead.

+ """.trimIndent(), + buttons = buttons("run"), + ), + PluginTooltipEntry( + tag = tagFor(JvmToolsPlugin.ACTION_RUN_CONFIGURATION), + summary = "Chooses which main class Run starts and the arguments it passes.", + detail = """ +

Lists every class in the project that has a main method: Java classes with + public static void main(String[] args), Kotlin files with a top-level + fun main(), and Kotlin objects with a @JvmStatic main.

+

Program arguments are split on spaces. Wrap an argument in quotes to keep its spaces, + for example --name "Ada Lovelace". The choice is saved for this project.

+ """.trimIndent(), + buttons = buttons("run-configuration"), + ), + ) + + fun tagFor(actionId: String): String = "${JvmToolsPlugin.PLUGIN_ID}.$actionId" + + private fun buttons(anchor: String): List = listOf( + PluginTooltipButton( + description = "How this command works", + uri = "index.html#$anchor", + order = 0, + ), + PluginTooltipButton( + description = "About Code on the Go plugins", + uri = "i/plugins-adfa.html", + order = 1, + directPath = true, + ), + ) +} diff --git a/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/JvmToolsPlugin.kt b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/JvmToolsPlugin.kt new file mode 100644 index 00000000..d21a476c --- /dev/null +++ b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/JvmToolsPlugin.kt @@ -0,0 +1,310 @@ +package com.appdevforall.jvm.plugin + +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import com.itsaky.androidide.plugins.IPlugin +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.extensions.BuildActionCategory +import com.itsaky.androidide.plugins.extensions.BuildActionExtension +import com.itsaky.androidide.plugins.extensions.CommandSpec +import com.itsaky.androidide.plugins.extensions.DocumentationExtension +import com.itsaky.androidide.plugins.extensions.PluginBuildAction +import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry +import com.itsaky.androidide.plugins.extensions.ToolbarAction +import com.itsaky.androidide.plugins.extensions.ToolbarActionIds +import com.itsaky.androidide.plugins.extensions.UIExtension +import com.itsaky.androidide.plugins.services.IdeEditorService +import com.itsaky.androidide.plugins.services.IdeEnvironmentService +import com.itsaky.androidide.plugins.services.IdeProjectService +import com.itsaky.androidide.plugins.services.IdeTemplateService +import com.itsaky.androidide.plugins.services.IdeTooltipService +import com.itsaky.androidide.plugins.services.IdeUIService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean + +class JvmToolsPlugin : IPlugin, BuildActionExtension, UIExtension, DocumentationExtension { + + private var pluginContext: PluginContext? = null + private var projectService: IdeProjectService? = null + private var editorService: IdeEditorService? = null + private var environmentService: IdeEnvironmentService? = null + private var templateService: IdeTemplateService? = null + private var uiService: IdeUIService? = null + private var tooltipService: IdeTooltipService? = null + private var configurations: RunConfigurationStore? = null + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val registeredTemplates = mutableListOf() + private val tooltipBindingScheduled = AtomicBoolean(false) + + override fun initialize(context: PluginContext): Boolean { + pluginContext = context + projectService = context.services.get(IdeProjectService::class.java) + editorService = context.services.get(IdeEditorService::class.java) + environmentService = context.services.get(IdeEnvironmentService::class.java) + templateService = context.services.get(IdeTemplateService::class.java) + uiService = context.services.get(IdeUIService::class.java) + tooltipService = context.services.get(IdeTooltipService::class.java) + configurations = RunConfigurationStore(File(context.resources.getPluginDirectory(), CONFIGURATIONS_FILE)) + return true + } + + override fun activate(): Boolean { + registerTemplates() + return true + } + + override fun deactivate(): Boolean { + templateService?.let { service -> registeredTemplates.forEach { service.unregisterTemplate(it) } } + registeredTemplates.clear() + return true + } + + override fun dispose() { + scope.cancel() + pluginContext = null + projectService = null + editorService = null + environmentService = null + templateService = null + uiService = null + tooltipService = null + configurations = null + } + + override fun toolbarActionsToHide(): Set { + if (!isJvmProjectOpen()) return emptySet() + scheduleTooltipBinding() + return ToolbarActionIds.BUILD_HIDEABLE + } + + override fun getHiddenToolbarActionIds(): Set = + if (isJvmProjectOpen()) setOf(QUICK_BUILD_ACTION_ID) else emptySet() + + override fun getToolbarActions(): List = listOf( + ToolbarAction( + id = ACTION_RUN_CONFIGURATION, + title = RUN_CONFIGURATION_LABEL, + icon = R.drawable.ic_run_configuration, + order = RUN_CONFIGURATION_ORDER, + action = ::showRunConfiguration, + ).apply { isVisibleProvider = ::isJvmProjectOpen } + ) + + override fun getBuildActions(): List { + if (!isJvmProjectOpen()) return emptyList() + val root = projectService?.getCurrentProject()?.rootDir ?: return emptyList() + val toolchain = resolveToolchain() ?: return emptyList() + scheduleTooltipBinding() + val configuration = effectiveConfiguration(root, MainClassFinder.find(root)) + return listOf( + PluginBuildAction( + id = ACTION_RUN, + name = RUN_LABEL, + description = configuration?.let { "Compile the project and run ${it.mainClass}" } + ?: "Compile the project and run its main class", + icon = R.drawable.ic_run_jvm, + category = BuildActionCategory.BUILD, + command = CommandSpec.ShellCommand("sh", listOf("-c", runScript(root, toolchain, configuration))), + timeoutMs = RUN_TIMEOUT_MS, + ) + ) + } + + override fun onActionStarted(actionId: String) { + if (actionId != ACTION_RUN) return + val editor = editorService ?: return + val root = projectService?.getCurrentProject()?.rootDir?.canonicalFile ?: return + val unsaved = editor.getModifiedFiles().filter { MainClassFinder.isSource(it) && it.canonicalFile.startsWith(root) } + unsaved.forEach { file -> editor.getFileContent(file)?.let(file::writeText) } + scope.launch { unsaved.forEach { editor.saveFile(it) } } + } + + override fun getTooltipCategory(): String = JvmToolsDocumentation.CATEGORY + + override fun getTooltipEntries(): List = JvmToolsDocumentation.entries() + + override fun getTier3DocsAssetPath(): String = JvmToolsDocumentation.DOCS_ASSET_PATH + + private fun isJvmProjectOpen(): Boolean = JvmDomain.isJvmProject(projectService?.getCurrentProject()?.rootDir) + + private fun effectiveConfiguration(root: File, mainClasses: List): RunConfiguration? { + val saved = configurations?.get(root) + if (saved != null && mainClasses.any { it.name == saved.mainClass }) return saved + val current = editorService?.getCurrentFile()?.canonicalFile + val main = mainClasses.firstOrNull { it.source.canonicalFile == current } ?: mainClasses.firstOrNull() ?: return null + return RunConfiguration(main.name, saved?.arguments ?: "") + } + + private fun showRunConfiguration() { + val root = projectService?.getCurrentProject()?.rootDir ?: return + val activity = uiService?.takeIf { it.isUIAvailable() }?.getCurrentActivity() ?: return + scope.launch { + val mainClasses = withContext(Dispatchers.IO) { MainClassFinder.find(root) } + val selected = effectiveConfiguration(root, mainClasses) ?: RunConfiguration("", "") + RunConfigurationDialog.show( + activity, + mainClasses, + selected, + onHelp = { view -> + tooltipService?.showTooltip( + view, + JvmToolsDocumentation.CATEGORY, + JvmToolsDocumentation.tagFor(ACTION_RUN_CONFIGURATION), + ) + }, + ) { configuration -> + scope.launch(Dispatchers.IO) { configurations?.put(root, configuration) } + } + } + } + + private fun registerTemplates() { + val service = templateService ?: return + val context = pluginContext ?: return + val template = service.createTemplateBuilder(TEMPLATE_NAME) + .description("A command-line program in Java or Kotlin. Choose the language when you create it, then tap Run.") + .showLanguageOption() + .thumbnailFromAssets("templates/console/thumb.png", context) + .addTemplateFromAssets("src/Main.java", "templates/console/Main.java.peb", context) + .addTemplateFromAssets("src/Greeter.java", "templates/console/Greeter.java.peb", context) + .addTemplateFromAssets("src/Main.kt", "templates/console/Main.kt.peb", context) + .addTemplateFromAssets("src/Greeter.kt", "templates/console/Greeter.kt.peb", context) + .addStaticFromAssets("README.md", "templates/console/README.md", context) + .build(context.resources.getPluginDirectory()) + if (service.registerTemplate(template)) registeredTemplates += template.name + } + + private fun resolveToolchain(): Toolchain? { + val tmp = environmentService?.getTmpDirectory() ?: return null + val prefix = tmp.parentFile ?: return null + val jdk = File(prefix, "lib/jvm/java-21-openjdk/bin") + return Toolchain( + java = File(jdk, "java"), + javac = File(jdk, "javac"), + mavenRepository = File(prefix.parentFile, "home/maven/localMvnRepository"), + workDirectory = File(tmp, "jvm-tools"), + ) + } + + private fun runScript(root: File, toolchain: Toolchain, configuration: RunConfiguration?): String = buildString { + append("JAVA=").append(shellQuote(toolchain.java.absolutePath)).append('\n') + append("JAVAC=").append(shellQuote(toolchain.javac.absolutePath)).append('\n') + append("MAVEN=").append(shellQuote(toolchain.mavenRepository.absolutePath)).append('\n') + append("PROJECT=").append(shellQuote(root.absolutePath)).append('\n') + append("WORK=").append(shellQuote(File(toolchain.workDirectory, root.absolutePath.hashCode().toUInt().toString(16)).absolutePath)).append('\n') + append("MAIN=").append(shellQuote(configuration?.mainClass ?: "")).append('\n') + append("set --") + ProgramArguments.parse(configuration?.arguments ?: "").forEach { append(' ').append(shellQuote(it)) } + append('\n') + append(RUN_SCRIPT) + } + + private fun shellQuote(value: String): String = "'" + value.replace("'", "'\\''") + "'" + + private fun scheduleTooltipBinding() { + if (tooltipService == null) return + val activity = uiService?.takeIf { it.isUIAvailable() }?.getCurrentActivity() ?: return + if (!tooltipBindingScheduled.compareAndSet(false, true)) return + activity.runOnUiThread { + val decor = activity.window?.decorView + if (decor == null) { + tooltipBindingScheduled.set(false) + return@runOnUiThread + } + decor.post { + tooltipBindingScheduled.set(false) + (decor as? ViewGroup)?.let(::bindTooltipsIn) + } + } + } + + private fun bindTooltipsIn(group: ViewGroup) { + for (index in 0 until group.childCount) { + when (val child = group.getChildAt(index)) { + is ImageButton -> bindTooltip(child) + is ViewGroup -> bindTooltipsIn(child) + } + } + } + + private fun bindTooltip(button: ImageButton) { + val tooltips = tooltipService ?: return + if (button.contentDescription?.toString() !in RUN_BUTTON_LABELS) return + button.setOnLongClickListener { view: View -> + tooltips.showTooltip(view, JvmToolsDocumentation.CATEGORY, JvmToolsDocumentation.tagFor(ACTION_RUN)) + true + } + } + + private data class Toolchain(val java: File, val javac: File, val mavenRepository: File, val workDirectory: File) + + companion object { + internal const val PLUGIN_ID = "com.appdevforall.jvm.plugin" + internal const val ACTION_RUN = "jvm.run" + internal const val ACTION_RUN_CONFIGURATION = "jvm.runConfiguration" + + private const val RUN_LABEL = "Run" + private const val RUN_CONFIGURATION_LABEL = "Run configuration" + private val RUN_BUTTON_LABELS = setOf(RUN_LABEL, "Cancel $RUN_LABEL") + private const val RUN_CONFIGURATION_ORDER = 14 + private const val RUN_TIMEOUT_MS = 30 * 60_000L + private const val QUICK_BUILD_ACTION_ID = "ide.editor.build.quickBuild" + private const val CONFIGURATIONS_FILE = "run-configurations.json" + private const val TEMPLATE_NAME = "Java/Kotlin Program" + + private val RUN_SCRIPT: String = """ + set -u + if [ -z "§MAIN" ]; then + echo "No main class found. Add a Java class with public static void main(String[] args), or a Kotlin file with a top-level fun main()." >&2 + exit 2 + fi + if [ ! -x "§JAVA" ] || [ ! -x "§JAVAC" ]; then + echo "The device JDK is missing from §(dirname "§JAVA")." >&2 + exit 3 + fi + cd "§PROJECT" || exit 2 + rm -rf "§WORK" + mkdir -p "§WORK/classes" + find . \( -name build -o -name '.?*' \) -prune -o -type f \( -name '*.java' -o -name '*.kt' \) -print | sort > "§WORK/sources" + quote() { sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/.*/"&"/'; } + grep '\.java§' "§WORK/sources" | quote > "§WORK/java-sources" + CP="§WORK/classes" + if grep -q '\.kt§' "§WORK/sources"; then + find_jar() { + for version in §(ls -1 "§MAVEN/§1" 2>/dev/null | sort -r); do + jar=§(ls -1 "§MAVEN/§1/§version"/*.jar 2>/dev/null | grep -vE '(-sources|-javadoc)\.jar§' | head -n1) + if [ -n "§jar" ]; then echo "§jar"; return 0; fi + done + return 1 + } + compiler=§(find_jar org/jetbrains/kotlin/kotlin-compiler-embeddable) || { echo "The Kotlin compiler is missing from §MAVEN." >&2; exit 4; } + stdlib=§(find_jar org/jetbrains/kotlin/kotlin-stdlib) || { echo "The Kotlin standard library is missing from §MAVEN." >&2; exit 4; } + boot="§compiler:§stdlib" + for artifact in org/jetbrains/kotlin/kotlin-script-runtime org/jetbrains/kotlin/kotlin-reflect \ + org/jetbrains/kotlin/kotlin-daemon-embeddable org/jetbrains/kotlinx/kotlinx-coroutines-core-jvm \ + org/jetbrains/annotations; do + jar=§(find_jar "§artifact") && boot="§boot:§jar" + done + quote < "§WORK/sources" > "§WORK/kotlin-sources" + echo ">> Compiling Kotlin" + "§JAVA" -Dkotlin.colors.enabled=false -cp "§boot" org.jetbrains.kotlin.cli.jvm.K2JVMCompiler \ + -no-stdlib -no-reflect -classpath "§stdlib" -d "§WORK/classes" @"§WORK/kotlin-sources" || exit 1 + CP="§CP:§stdlib" + fi + if [ -s "§WORK/java-sources" ]; then + echo ">> Compiling Java" + "§JAVAC" -cp "§CP" -d "§WORK/classes" @"§WORK/java-sources" || exit 1 + fi + echo ">> Running §MAIN" + exec "§JAVA" -cp "§CP" "§MAIN" "§@" + """.trimIndent().replace('§', '$') + } +} diff --git a/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/MainClassFinder.kt b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/MainClassFinder.kt new file mode 100644 index 00000000..3a365c3c --- /dev/null +++ b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/MainClassFinder.kt @@ -0,0 +1,83 @@ +package com.appdevforall.jvm.plugin + +import java.io.File + +internal data class MainClass(val name: String, val source: File) + +internal object MainClassFinder { + + private const val MAX_SOURCES = 2000 + + private val COMMENT = Regex("""/\*[\s\S]*?\*/|//[^\n]*""") + private val JAVA_PACKAGE = Regex("""^\s*package\s+([\w.]+)\s*;""", RegexOption.MULTILINE) + private val JAVA_TYPE = Regex("""\b(?:class|interface|enum|record)\s+(\w+)""") + private val JAVA_MAIN = Regex( + """\bstatic\s+(?:(?:public|final|synchronized|strictfp)\s+)*void\s+main\s*\(\s*(?:final\s+)?String\s*(?:\[\s*]|\.\.\.|\s\w+\s*\[\s*])""" + ) + private val KOTLIN_PACKAGE = Regex("""^\s*package\s+([\w.]+)""", RegexOption.MULTILINE) + private val KOTLIN_JVM_NAME = Regex("""@file:JvmName\(\s*"(\w+)"\s*\)""") + private val KOTLIN_TOP_LEVEL_MAIN = Regex("""^(?:(?:public|suspend)\s+)*fun\s+main\s*\(""", RegexOption.MULTILINE) + private val KOTLIN_JVM_STATIC_MAIN = Regex("""@JvmStatic\s+(?:(?:public|suspend)\s+)*fun\s+main\s*\(""") + private val KOTLIN_OWNER = Regex("""\bobject\s+(\w+)|\bcompanion\s+object\b""") + private val KOTLIN_CLASS = Regex("""\bclass\s+(\w+)""") + + fun isSource(file: File): Boolean = file.extension == "java" || file.extension == "kt" + + fun isSkipped(dir: File): Boolean = dir.name == "build" || dir.name.startsWith(".") + + fun find(root: File): List = + sources(root) + .flatMap { file -> mainClassNames(file.name, file.readText()).map { MainClass(it, file) } } + .distinctBy { it.name } + .sortedBy { it.name } + + fun sources(root: File): List = + root.walkTopDown() + .onEnter { it == root || !isSkipped(it) } + .filter { it.isFile && isSource(it) } + .take(MAX_SOURCES) + .toList() + + fun mainClassNames(fileName: String, text: String): List { + val code = COMMENT.replace(text, "") + return when { + fileName.endsWith(".java") -> javaMainClasses(code) + fileName.endsWith(".kt") -> kotlinMainClasses(fileName, code) + else -> emptyList() + } + } + + private fun javaMainClasses(code: String): List { + val pkg = JAVA_PACKAGE.find(code)?.groupValues?.get(1) + return JAVA_MAIN.findAll(code) + .mapNotNull { main -> JAVA_TYPE.findAll(code.substring(0, main.range.first)).lastOrNull()?.groupValues?.get(1) } + .map { qualify(pkg, it) } + .toList() + } + + private fun kotlinMainClasses(fileName: String, code: String): List { + val pkg = KOTLIN_PACKAGE.find(code)?.groupValues?.get(1) + val names = mutableListOf() + if (KOTLIN_TOP_LEVEL_MAIN.containsMatchIn(code)) { + names += KOTLIN_JVM_NAME.find(code)?.groupValues?.get(1) ?: facadeClassName(fileName) + } + KOTLIN_JVM_STATIC_MAIN.findAll(code).forEach { main -> + ownerOf(code.substring(0, main.range.first))?.let { names += it } + } + return names.map { qualify(pkg, it) } + } + + private fun ownerOf(before: String): String? { + val owner = KOTLIN_OWNER.findAll(before).lastOrNull() ?: return null + return owner.groups[1]?.value + ?: KOTLIN_CLASS.findAll(before.substring(0, owner.range.first)).lastOrNull()?.groupValues?.get(1) + } + + private fun facadeClassName(fileName: String): String { + val base = fileName.removeSuffix(".kt").map { if (it.isJavaIdentifierPart()) it else '_' }.joinToString("") + val start = if (base.firstOrNull()?.isJavaIdentifierStart() == true) base else "_$base" + return start.replaceFirstChar { it.uppercaseChar() } + "Kt" + } + + private fun qualify(pkg: String?, name: String): String = if (pkg.isNullOrEmpty()) name else "$pkg.$name" +} diff --git a/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/ProgramArguments.kt b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/ProgramArguments.kt new file mode 100644 index 00000000..26a44d7d --- /dev/null +++ b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/ProgramArguments.kt @@ -0,0 +1,44 @@ +package com.appdevforall.jvm.plugin + +internal object ProgramArguments { + + fun parse(input: String): List { + val args = mutableListOf() + val current = StringBuilder() + var inArgument = false + var quote: Char? = null + var i = 0 + while (i < input.length) { + val c = input[i] + when { + quote == '\'' -> if (c == '\'') quote = null else current.append(c) + quote == '"' -> when { + c == '"' -> quote = null + c == '\\' && i + 1 < input.length && input[i + 1] in "\"\\" -> current.append(input[++i]) + else -> current.append(c) + } + c.isWhitespace() -> if (inArgument) { + args += current.toString() + current.clear() + inArgument = false + } + c == '\'' || c == '"' -> { + quote = c + inArgument = true + } + c == '\\' && i + 1 < input.length -> { + current.append(input[++i]) + inArgument = true + } + else -> { + current.append(c) + inArgument = true + } + } + i++ + } + require(quote == null) { "Close the $quote quote in the program arguments." } + if (inArgument) args += current.toString() + return args + } +} diff --git a/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/RunConfigurationDialog.kt b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/RunConfigurationDialog.kt new file mode 100644 index 00000000..e77a4ae1 --- /dev/null +++ b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/RunConfigurationDialog.kt @@ -0,0 +1,112 @@ +package com.appdevforall.jvm.plugin + +import android.app.Activity +import android.content.DialogInterface +import android.text.InputType +import android.util.TypedValue +import android.view.View +import android.widget.LinearLayout +import android.widget.RadioGroup +import android.widget.ScrollView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.radiobutton.MaterialRadioButton +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.google.android.material.textview.MaterialTextView +import com.itsaky.androidide.plugins.base.PluginWindows + +internal object RunConfigurationDialog { + + private const val TITLE = "Run configuration" + + fun show( + activity: Activity, + mainClasses: List, + selected: RunConfiguration, + onHelp: (View) -> Unit, + onSave: (RunConfiguration) -> Unit, + ) { + if (mainClasses.isEmpty()) { + PluginWindows.showDialog( + MaterialAlertDialogBuilder(activity) + .setTitle(TITLE) + .setMessage( + "No main class found. Add a Java class with public static void main(String[] args), " + + "or a Kotlin file with a top-level fun main()." + ) + .setPositiveButton(android.R.string.ok, null) + .create() + ) + return + } + + val classes = RadioGroup(activity) + mainClasses.forEachIndexed { index, main -> + classes.addView(MaterialRadioButton(activity).apply { + id = index + 1 + text = main.name + }) + } + classes.check(mainClasses.indexOfFirst { it.name == selected.mainClass }.coerceAtLeast(0) + 1) + + val argumentsLayout = TextInputLayout(activity).apply { + hint = "Program arguments" + helperText = "Separate arguments with spaces. Quote an argument that contains spaces." + setOnLongClickListener { view -> + onHelp(view) + true + } + } + val arguments = TextInputEditText(argumentsLayout.context).apply { + setText(selected.arguments) + inputType = InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_FLAG_MULTI_LINE or + InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + } + argumentsLayout.addView(arguments) + + val padding = dp(activity, 24) + val content = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + setPadding(padding, dp(activity, 8), padding, 0) + addView(MaterialTextView(activity).apply { + text = "Main class" + setTextAppearance(android.R.style.TextAppearance_Material_Subhead) + setOnLongClickListener { view -> + onHelp(view) + true + } + }) + addView(classes) + addView( + argumentsLayout, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) + .apply { topMargin = dp(activity, 16) }, + ) + } + + val dialog = MaterialAlertDialogBuilder(activity) + .setTitle(TITLE) + .setView(ScrollView(activity).apply { addView(content) }) + .setPositiveButton("Save", null) + .setNegativeButton(android.R.string.cancel, null) + .create() + dialog.setOnShowListener { + dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { + val text = arguments.text?.toString().orEmpty() + try { + ProgramArguments.parse(text) + } catch (e: IllegalArgumentException) { + argumentsLayout.error = e.message + return@setOnClickListener + } + onSave(RunConfiguration(mainClasses[classes.checkedRadioButtonId - 1].name, text)) + dialog.dismiss() + } + } + PluginWindows.showDialog(dialog) + } + + private fun dp(activity: Activity, value: Int): Int = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), activity.resources.displayMetrics).toInt() +} diff --git a/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/RunConfigurationStore.kt b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/RunConfigurationStore.kt new file mode 100644 index 00000000..9eb016d9 --- /dev/null +++ b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/RunConfigurationStore.kt @@ -0,0 +1,36 @@ +package com.appdevforall.jvm.plugin + +import org.json.JSONObject +import java.io.File + +internal data class RunConfiguration(val mainClass: String, val arguments: String) + +internal class RunConfigurationStore(private val file: File) { + + @Synchronized + fun get(projectRoot: File): RunConfiguration? { + val entry = read().optJSONObject(projectRoot.absolutePath) ?: return null + return RunConfiguration(entry.getString(KEY_MAIN_CLASS), entry.getString(KEY_ARGUMENTS)) + } + + @Synchronized + fun put(projectRoot: File, configuration: RunConfiguration) { + val all = read().put( + projectRoot.absolutePath, + JSONObject() + .put(KEY_MAIN_CLASS, configuration.mainClass) + .put(KEY_ARGUMENTS, configuration.arguments), + ) + file.parentFile?.mkdirs() + val staged = File(file.parentFile, "${file.name}.tmp") + staged.writeText(all.toString()) + check(staged.renameTo(file)) { "Could not save the run configuration to $file" } + } + + private fun read(): JSONObject = if (file.isFile) JSONObject(file.readText()) else JSONObject() + + private companion object { + const val KEY_MAIN_CLASS = "mainClass" + const val KEY_ARGUMENTS = "arguments" + } +} diff --git a/plugins/JVM-Tools/src/main/res/drawable/ic_run_configuration.xml b/plugins/JVM-Tools/src/main/res/drawable/ic_run_configuration.xml new file mode 100644 index 00000000..7349d835 --- /dev/null +++ b/plugins/JVM-Tools/src/main/res/drawable/ic_run_configuration.xml @@ -0,0 +1,9 @@ + + + diff --git a/plugins/JVM-Tools/src/main/res/drawable/ic_run_jvm.xml b/plugins/JVM-Tools/src/main/res/drawable/ic_run_jvm.xml new file mode 100644 index 00000000..88f62917 --- /dev/null +++ b/plugins/JVM-Tools/src/main/res/drawable/ic_run_jvm.xml @@ -0,0 +1,9 @@ + + + diff --git a/plugins/JVM-Tools/src/test/kotlin/com/appdevforall/jvm/plugin/MainClassFinderTest.kt b/plugins/JVM-Tools/src/test/kotlin/com/appdevforall/jvm/plugin/MainClassFinderTest.kt new file mode 100644 index 00000000..156136bd --- /dev/null +++ b/plugins/JVM-Tools/src/test/kotlin/com/appdevforall/jvm/plugin/MainClassFinderTest.kt @@ -0,0 +1,90 @@ +package com.appdevforall.jvm.plugin + +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class MainClassFinderTest { + + @get:Rule + val folder = TemporaryFolder() + + @Test + fun `java main is named after the class that declares it, with its package`() { + val source = """ + package com.example.app; + + class Helper {} + + public final class Launcher { + public static void main(String[] args) {} + } + """.trimIndent() + + assertEquals(listOf("com.example.app.Launcher"), MainClassFinder.mainClassNames("Launcher.java", source)) + } + + @Test + fun `java main accepts varargs, c-style arrays and modifiers in any order`() { + assertEquals(listOf("A"), MainClassFinder.mainClassNames("A.java", "class A { static public void main(String... a) {} }")) + assertEquals(listOf("B"), MainClassFinder.mainClassNames("B.java", "class B { public static void main(String a[]) {} }")) + } + + @Test + fun `java without a runnable main is not listed`() { + val source = """ + class A { + // public static void main(String[] args) {} + /* public static void main(String[] args) {} */ + public void main(String[] args) {} + static void main(int x) {} + } + """.trimIndent() + + assertEquals(emptyList(), MainClassFinder.mainClassNames("A.java", source)) + } + + @Test + fun `kotlin top-level main runs as the file facade`() { + assertEquals(listOf("tools.MainKt"), MainClassFinder.mainClassNames("main.kt", "package tools\n\nfun main() {}\n")) + assertEquals(listOf("My_appKt"), MainClassFinder.mainClassNames("my-app.kt", "suspend fun main(args: Array) {}\n")) + assertEquals(listOf("Launcher"), MainClassFinder.mainClassNames("App.kt", "@file:JvmName(\"Launcher\")\nfun main() {}\n")) + } + + @Test + fun `kotlin jvmstatic main runs in its object or in the class of its companion`() { + val source = """ + object Tool { + @JvmStatic fun main(args: Array) {} + } + + class App { + companion object { + @JvmStatic + fun main(args: Array) {} + } + } + """.trimIndent() + + assertEquals(listOf("Tool", "App"), MainClassFinder.mainClassNames("Both.kt", source)) + } + + @Test + fun `kotlin main inside a class without JvmStatic is not listed`() { + assertEquals(emptyList(), MainClassFinder.mainClassNames("A.kt", "class A {\n fun main() {}\n}\n")) + } + + @Test + fun `find walks the project, skipping build and hidden folders`() { + folder.newFolder("src", "tools") + folder.newFile("src/Main.java").writeText("public class Main { public static void main(String[] a) {} }") + folder.newFile("src/tools/Echo.kt").writeText("package tools\nfun main() {}\n") + folder.newFolder("build") + folder.newFile("build/Old.java").writeText("class Old { public static void main(String[] a) {} }") + folder.newFolder(".cg") + folder.newFile(".cg/Hidden.kt").writeText("fun main() {}\n") + + assertEquals(listOf("Main", "tools.EchoKt"), MainClassFinder.find(folder.root).map { it.name }) + } +} diff --git a/plugins/JVM-Tools/src/test/kotlin/com/appdevforall/jvm/plugin/ProgramArgumentsTest.kt b/plugins/JVM-Tools/src/test/kotlin/com/appdevforall/jvm/plugin/ProgramArgumentsTest.kt new file mode 100644 index 00000000..813efe0b --- /dev/null +++ b/plugins/JVM-Tools/src/test/kotlin/com/appdevforall/jvm/plugin/ProgramArgumentsTest.kt @@ -0,0 +1,30 @@ +package com.appdevforall.jvm.plugin + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class ProgramArgumentsTest { + + @Test + fun `splits on whitespace`() { + assertEquals(listOf("a", "b", "c"), ProgramArguments.parse(" a b\t\nc ")) + assertEquals(emptyList(), ProgramArguments.parse(" ")) + } + + @Test + fun `quotes keep spaces and can hold the other quote`() { + assertEquals(listOf("--name", "Ada Lovelace", "it's"), ProgramArguments.parse("--name \"Ada Lovelace\" \"it's\"")) + assertEquals(listOf("say \"hi\"", "a\\b"), ProgramArguments.parse("'say \"hi\"' 'a\\b'")) + } + + @Test + fun `backslash escapes outside single quotes`() { + assertEquals(listOf("a b", "\"x\"", ""), ProgramArguments.parse("a\\ b \"\\\"x\\\"\" \"\"")) + } + + @Test + fun `an unclosed quote is rejected`() { + assertThrows(IllegalArgumentException::class.java) { ProgramArguments.parse("\"Ada") } + } +}