diff --git a/.android-dynamic-modules.properties b/.android-dynamic-modules.properties new file mode 100644 index 0000000..ed2f3d4 --- /dev/null +++ b/.android-dynamic-modules.properties @@ -0,0 +1,5 @@ +# Project-specific defaults used by the plugin when this repository is opened in the sandbox IDE. +applicationModule=:app +requiredModules=:app +hiddenModules=:hiddenmodule +dynamicFeaturePrefixes=df_ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..43a6f0c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +gradlew text eol=lf +gradlew.bat text eol=crlf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f3f749d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 + +updates: + - package-ecosystem: gradle + directory: / + schedule: + interval: weekly + groups: + gradle-dependencies: + patterns: ["*"] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: ["*"] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..4bc3720 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,51 @@ +name: Build + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: Test, build, and verify + runs-on: ubuntu-latest + steps: + - name: Check out sources + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: zulu + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Run quality gates + run: ./gradlew check buildPlugin verifyPlugin --stacktrace + + - name: Upload plugin distribution + uses: actions/upload-artifact@v7 + with: + name: android-dynamic-modules + path: build/distributions/*.zip + if-no-files-found: error + + - name: Upload reports on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: verification-reports + path: | + build/reports/tests + build/reports/pluginVerifier + build/reports/problems + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index b63da45..84fe95e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,13 @@ .gradle +.intellijPlatform/ +.kotlin/ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ ### IntelliJ IDEA ### -.idea/modules.xml -.idea/jarRepositories.xml -.idea/compiler.xml -.idea/libraries/ +.idea/ *.iws *.iml *.ipr @@ -39,4 +38,8 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store + +### Plugin sandbox ### +build/idea-sandbox/ +*.hprof diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d3352..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index 4bbf730..0000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -ADP_Github \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index b7d8296..0000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 146ab09..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml deleted file mode 100644 index 4cb7457..0000000 --- a/.idea/kotlinc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 477d1e2..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9b0085d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## 1.0.0 - Unreleased + +### Added + +- Safe parsing for Groovy and Kotlin Gradle settings. +- Dynamic-feature synchronization for Groovy and Kotlin application build files. +- Project configuration through `.android-dynamic-modules.properties`. +- Required and hidden modules, filterable UI, and light/default presets. +- Unit and regression tests plus GitHub Actions verification. + +### Changed + +- Migrated to IntelliJ Platform Gradle Plugin 2.x, Gradle 9.5, and JDK 21. +- Replaced placeholder plugin metadata and package names. +- Applied module changes as one validated IDE write command. + +### Fixed + +- Prevented missing or unreadable Gradle files from being replaced with empty content. +- Added consistent support for `build.gradle.kts` and `build.gradle`. +- Removed substring-based module matching and destructive comment replacement. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ce0ca7e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# Contributing + +## Development workflow + +1. Create a branch from `main`. +2. Keep Gradle parsing and rendering logic independent from IntelliJ UI classes. +3. Add regression tests for every supported syntax or fixed failure mode. +4. Run `./gradlew check buildPlugin verifyPlugin` with JDK 21. +5. Open a pull request describing the affected Gradle syntax and safety behavior. + +## Safety rules + +- Never turn a read or parse error into empty file content. +- Prepare and validate all changes before mutating an IDE document. +- Preserve files byte-for-byte when a declaration is unsupported. +- Normalize and compare complete Gradle module paths; do not use substring matching. +- Keep file I/O away from the Swing event-dispatch thread. + +## Tests + +Parser fixtures should cover both Kotlin and Groovy DSL when applicable. Include cases for comments, multiline declarations, nested modules, empty dynamic-feature sets, and repeated application of the renderer. diff --git a/README.md b/README.md index bd6c465..8dc52b7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,113 @@ -# Android Dynamic Modules Plugin +# Android Dynamic Modules -This repo is a template for Medium Guide on how to reduce Android compilation time that you can find here: https://medium.com/@fazi.ruben/from-frustration-to-efficiency-how-i-reduced-android-compilation-time-by-70-e0a57fd0bb09 \ No newline at end of file +An IntelliJ Platform plugin that lets Android developers enable or disable Gradle modules from the IDE. When selected modules are Android dynamic features, the plugin also keeps the base application's `dynamicFeatures` declaration aligned and starts a Gradle sync. + +This repository accompanies [How I Reduced Android Compilation Time by 70%](https://medium.com/@fazi.ruben/from-frustration-to-efficiency-how-i-reduced-android-compilation-time-by-70-e0a57fd0bb09), but the implementation has been hardened for use outside the original sample project. + +## What it supports + +- `settings.gradle.kts` and `settings.gradle`. +- Kotlin and Groovy `include(...)` syntax. +- Multiple and multiline module declarations. +- Nested paths such as `:feature:payments`. +- Kotlin and Groovy Android `dynamicFeatures` declarations. +- Required modules, hidden modules, custom application directories, and configurable dynamic-feature prefixes. +- A fail-closed change plan: every target is parsed and validated before any IDE document is changed. +- One grouped IDE write command followed by Gradle sync only when files changed. + +## Usage + +1. Open a Gradle project in Android Studio or IntelliJ IDEA with the Gradle plugin installed. +2. Select **Tools → Manage Android Modules**. +3. Choose the modules to keep enabled, or apply one of the presets. +4. Select **Apply and Sync**. + +The plugin normalizes module paths to Gradle's `:module:path` form. Required modules cannot be deselected. Hidden modules preserve their current state and are not shown in the dialog. + +The Gradle files are version-controlled project files. Review or revert their changes with the IDE or Git before committing them. + +## Project configuration + +Add an optional `.android-dynamic-modules.properties` file to the project root: + +```properties +applicationModule=:app +# Optional when the application module has a custom projectDir. +# applicationModuleDirectory=applications/mobile +requiredModules=:app,:core +hiddenModules=:build-logic +dynamicFeaturePrefixes=df_,dynamic_ +``` + +All module lists are comma-separated. Without this file the defaults are: + +- application and required module: `:app`; +- no hidden modules; +- dynamic-feature prefix: `df_`. + +A module already listed in the application's `dynamicFeatures` declaration is recognized as dynamic even when it does not match a configured prefix. + +## Safe editing model + +The plugin never writes an empty fallback after a read error. Its workflow is: + +1. resolve exactly one settings file and, when required, exactly one application build file; +2. parse all literal module declarations; +3. prepare the complete changes in memory; +4. verify that open IDE documents still match the loaded snapshot; +5. update the documents as one IDE command; +6. save and start Gradle sync. + +If a file changed while the dialog was open, a dynamic-feature declaration is missing, both Groovy and Kotlin variants exist, or a statement is structurally invalid, the operation stops without applying the plan. + +## Supported declaration examples + +```kotlin +include(":app", ":feature:login") +// include(":optional") + +android { + dynamicFeatures += setOf(":df_payments", ":df_profile") +} +``` + +```groovy +include ':app', ':feature:login' + +android { + dynamicFeatures = [':df_payments', ':df_profile'] +} +``` + +Only literal quoted module paths are managed. Computed includes, version-catalog logic, custom functions, and arbitrary Gradle expressions are intentionally left untouched. + +## Development + +Requirements: + +- JDK 21; +- Gradle Wrapper included in this repository. + +Run the quality gates: + +```shell +./gradlew check buildPlugin verifyPlugin +``` + +Useful tasks: + +```shell +./gradlew runIde +./gradlew test +./gradlew buildPlugin +``` + +The `app`, `optional`, and `hiddenmodule` subprojects are lightweight sandbox fixtures. Their behavior is described by the repository's `.android-dynamic-modules.properties` file. + +## Compatibility + +The plugin is built with IntelliJ Platform Gradle Plugin 2.x, targets IntelliJ Platform build `242` or newer, and declares a required dependency on the bundled Gradle plugin. Compatibility is checked by CI with the IntelliJ Plugin Verifier. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). Please include tests for parser, renderer, or file-planning changes. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9e19616..78a1e40 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,21 +1,5 @@ plugins { - kotlin("jvm") + base } -group = "com.example" -version = "1.0-SNAPSHOT" - -repositories { - mavenCentral() -} - -dependencies { - testImplementation(kotlin("test")) -} - -tasks.test { - useJUnitPlatform() -} -kotlin { - jvmToolchain(21) -} \ No newline at end of file +description = "Required sample module used by the plugin development sandbox" diff --git a/app/src/main/kotlin/Main.kt b/app/src/main/kotlin/Main.kt deleted file mode 100644 index 483a4f4..0000000 --- a/app/src/main/kotlin/Main.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.example - -//TIP To Run code, press or -// click the icon in the gutter. -fun main() { - val name = "Kotlin" - //TIP Press with your caret at the highlighted text - // to see how IntelliJ IDEA suggests fixing it. - println("Hello, " + name + "!") - - for (i in 1..5) { - //TIP Press to start debugging your code. We have set one breakpoint - // for you, but you can always add more by pressing . - println("i = $i") - } -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index ccfa825..32a2ccb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,52 +1,58 @@ +import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType +import org.jetbrains.intellij.platform.gradle.tasks.VerifyPluginTask +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { - id("java") - id("org.jetbrains.kotlin.jvm") version "1.9.25" - id("org.jetbrains.intellij") version "1.17.4" + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.intellij.platform") } -group = "com.example" -version = "1.0-SNAPSHOT" - -repositories { - mavenCentral() -} +group = providers.gradleProperty("pluginGroup").get() +version = providers.gradleProperty("pluginVersion").get() -// Configure Gradle IntelliJ Plugin -// Read more: https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html -intellij { - // FOR DEBUG - //version.set("2024.2.2") //2024.2.2 Patch 1 - //type.set("AI") // android studio +dependencies { + testImplementation(kotlin("test-junit")) - // FOR BUILD - version.set("2024.1.7") - type.set("IC") // Target IDE Platform + intellijPlatform { + create(IntelliJPlatformType.IntellijIdeaCommunity, "2024.2.6") { + useInstaller = false + } + bundledPlugin("org.jetbrains.plugins.gradle") + } +} - plugins.set(listOf(/* Plugin Dependencies */)) +kotlin { + jvmToolchain(21) + compilerOptions.jvmTarget.set(JvmTarget.JVM_21) } -tasks { - // Set the JVM compatibility versions - withType { - sourceCompatibility = "17" - targetCompatibility = "17" - } - withType { - kotlinOptions.jvmTarget = "17" +intellijPlatform { + buildSearchableOptions = false + + pluginConfiguration { + ideaVersion { + sinceBuild = "242" + untilBuild = provider { null } + } } - patchPluginXml { - sinceBuild.set("221") //Electric Eel compatibility - untilBuild.set("243.*") + pluginVerification { + ides { + current() + } } +} - signPlugin { - certificateChain.set(System.getenv("CERTIFICATE_CHAIN")) - privateKey.set(System.getenv("PRIVATE_KEY")) - password.set(System.getenv("PRIVATE_KEY_PASSWORD")) +tasks { + withType().configureEach { + sourceCompatibility = "21" + targetCompatibility = "21" } - publishPlugin { - token.set(System.getenv("PUBLISH_TOKEN")) + withType().configureEach { + systemProperty( + "plugin.verifier.home.dir", + layout.buildDirectory.dir("pluginVerifier-home").get().asFile.absolutePath, + ) } } diff --git a/gradle.properties b/gradle.properties index 24630b3..de996e1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,9 @@ -# Opt-out flag for bundling Kotlin standard library -> https://jb.gg/intellij-platform-kotlin-stdlib +pluginGroup=io.github.rfazi.androiddynamicmodules +pluginVersion=1.0.0 + +# IntelliJ Platform provides Kotlin at runtime. kotlin.stdlib.default.dependency=false -# Enable Gradle Configuration Cache -> https://docs.gradle.org/current/userguide/configuration_cache.html + org.gradle.configuration-cache=true -# Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html org.gradle.caching=true +org.gradle.parallel=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e583..b1b8ef5 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 81aa1c0..b52fb7e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1b6c787..b9bb139 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +82,11 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -133,22 +132,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -165,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -193,18 +198,27 @@ if "$cygwin" || "$msys" ; then done fi -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/gradlew.bat b/gradlew.bat index 107acd3..24c62d5 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,19 +13,22 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,15 +43,15 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -56,34 +59,24 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/hiddenmodule/build.gradle.kts b/hiddenmodule/build.gradle.kts index f41a758..653ebba 100644 --- a/hiddenmodule/build.gradle.kts +++ b/hiddenmodule/build.gradle.kts @@ -1,21 +1,5 @@ plugins { - kotlin("jvm") + base } -group = "com.example" -version = "1.0-SNAPSHOT" - -repositories { - mavenCentral() -} - -dependencies { - testImplementation(kotlin("test")) -} - -tasks.test { - useJUnitPlatform() -} -kotlin { - jvmToolchain(17) -} \ No newline at end of file +description = "Hidden sample module used by the plugin development sandbox" diff --git a/hiddenmodule/src/main/kotlin/Main.kt b/hiddenmodule/src/main/kotlin/Main.kt deleted file mode 100644 index 483a4f4..0000000 --- a/hiddenmodule/src/main/kotlin/Main.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.example - -//TIP To Run code, press or -// click the icon in the gutter. -fun main() { - val name = "Kotlin" - //TIP Press with your caret at the highlighted text - // to see how IntelliJ IDEA suggests fixing it. - println("Hello, " + name + "!") - - for (i in 1..5) { - //TIP Press to start debugging your code. We have set one breakpoint - // for you, but you can always add more by pressing . - println("i = $i") - } -} \ No newline at end of file diff --git a/optional/build.gradle.kts b/optional/build.gradle.kts index 9e19616..19e9d23 100644 --- a/optional/build.gradle.kts +++ b/optional/build.gradle.kts @@ -1,21 +1,5 @@ plugins { - kotlin("jvm") + base } -group = "com.example" -version = "1.0-SNAPSHOT" - -repositories { - mavenCentral() -} - -dependencies { - testImplementation(kotlin("test")) -} - -tasks.test { - useJUnitPlatform() -} -kotlin { - jvmToolchain(21) -} \ No newline at end of file +description = "Optional sample module used by the plugin development sandbox" diff --git a/optional/src/main/kotlin/Main.kt b/optional/src/main/kotlin/Main.kt deleted file mode 100644 index 483a4f4..0000000 --- a/optional/src/main/kotlin/Main.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.example - -//TIP To Run code, press or -// click the icon in the gutter. -fun main() { - val name = "Kotlin" - //TIP Press with your caret at the highlighted text - // to see how IntelliJ IDEA suggests fixing it. - println("Hello, " + name + "!") - - for (i in 1..5) { - //TIP Press to start debugging your code. We have set one breakpoint - // for you, but you can always add more by pressing . - println("i = $i") - } -} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 29e994b..907a67c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,14 +1,26 @@ +import org.jetbrains.intellij.platform.gradle.extensions.intellijPlatform + +rootProject.name = "AndroidDynamicModules" + pluginManagement { - repositories { - mavenCentral() - gradlePluginPortal() + plugins { + id("org.jetbrains.kotlin.jvm") version "2.1.20" } } + plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" + id("org.jetbrains.intellij.platform.settings") version "2.18.1" +} + +@Suppress("UnstableApiUsage") +dependencyResolutionManagement { + repositories { + mavenCentral() + intellijPlatform { + defaultRepositories() + } + } } -rootProject.name = "ADP_Github" -include("hiddenmodule") -include("app") -include("optional") +include(":app", ":hiddenmodule", ":optional") diff --git a/src/main/kotlin/com/example/adp_github/ModuleManagerForm.kt b/src/main/kotlin/com/example/adp_github/ModuleManagerForm.kt deleted file mode 100644 index b30886a..0000000 --- a/src/main/kotlin/com/example/adp_github/ModuleManagerForm.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.example.adp_github - -import com.intellij.openapi.project.Project -import com.intellij.ui.components.JBScrollPane -import java.nio.file.Files -import java.nio.file.Paths -import javax.swing.* - - -class ModuleManagerForm(private val project: Project) { - private val mainPanel = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - add(JLabel("Remember: DON'T push edited gradle files!!!")) - add(JLabel("Project: ${project.basePath}")) - add(JButton("Default Preset").apply { - addActionListener { - modulesCheckBox.forEach { - it.value.isSelected = true - } - } - }) - add(JButton("Light Preset").apply { - addActionListener { - modulesCheckBox.forEach { - it.value.isSelected = disabled.contains(it.key) - } - } - }) - } - private val modulesCheckBox: MutableMap = mutableMapOf() - private val disabled = listOf( - "app" - ) - private val hiddenModules = listOf( - "hiddenmodule" - ) - - init { - loadModuleStates() - modulesCheckBox.toSortedMap().forEach { mainPanel.add(it.value) } - } - - private fun loadModuleStates() { - kotlin.runCatching { - val lines: List = kotlin.runCatching { - Files.readAllLines(Paths.get(project.basePath, "settings.gradle.kts")) - }.getOrElse { - Files.readAllLines(Paths.get(project.basePath, "settings.gradle")) - } - - lines.forEach { line -> - if (line.contains("include")) { - val module = Regex("include\\s?[\"'(]{1,2}(?[^\"']+)[\"')]{1,2}").find(line) - module?.let { - kotlin.runCatching { - it.groups["name"]?.let { name -> - modulesCheckBox[name.value] = JCheckBox(name.value).apply { - isSelected = line.contains("//").not() - isEnabled = disabled.contains(name.value).not() - isVisible = hiddenModules.contains(name.value).not() - addActionListener { - modulesCheckBox[name.value]!!.isSelected = isSelected - } - } - } - } - } - } - } - }.onFailure { it.printStackTrace() } - } - - private fun applyChanges() { - kotlin.runCatching { - val oldSettingsFile = Paths.get(project.basePath, "settings.gradle").toFile() - val ktsSettingsFile = Paths.get(project.basePath, "settings.gradle.kts").toFile() - val appGradleFile = Paths.get(project.basePath, "app", "build.gradle").toFile() - - val settingsFile = if (oldSettingsFile.exists()) oldSettingsFile else ktsSettingsFile - - val settingLines: List = kotlin.runCatching { - Files.readAllLines(settingsFile.toPath()) - }.getOrElse { - it.printStackTrace() - emptyList() - } - - val newSettingsLines = settingLines.map { line: String -> - if (line.contains("include")) { - modulesCheckBox.forEach { - if (line.contains(it.key)) { - val newLine = line.replace("//", "") - return@map if (it.value.isSelected) newLine else "//$newLine" - } - } - } - line - } - - Files.write(settingsFile.toPath(), newSettingsLines) - - val appGradleLines: List = kotlin.runCatching { - Files.readAllLines(appGradleFile.toPath()) - }.getOrElse { - it.printStackTrace() - emptyList() - } - - val newAppGradleLines = appGradleLines.map { line: String -> - if (line.contains("dynamicFeatures")) { - val df = modulesCheckBox.filter { it.key.contains("df_") && it.value.isSelected } - .map { - "'${it.key}'" - } - return@map "\tdynamicFeatures = [${df.joinToString(", ")}]" - } - line - } - - Files.write(appGradleFile.toPath(), newAppGradleLines) - }.onFailure { it.printStackTrace() } - } - - fun getMainPanel(): JPanel { - return mainPanel - } - - fun getScrollPane(): JScrollPane { - return JBScrollPane(mainPanel) - } - - fun saveChanges() { - applyChanges() - } - - private fun putLog(log: String) { - mainPanel.add(JLabel(log)) - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/example/adp_github/TestAction.kt b/src/main/kotlin/com/example/adp_github/TestAction.kt deleted file mode 100644 index 06566f4..0000000 --- a/src/main/kotlin/com/example/adp_github/TestAction.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.example.adp_github - -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.externalSystem.importing.ImportSpecBuilder -import com.intellij.openapi.externalSystem.model.ProjectSystemId -import com.intellij.openapi.externalSystem.util.ExternalSystemUtil -import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.DialogWrapper -import javax.swing.JComponent - - -class TestAction: AnAction() { - override fun actionPerformed(e: AnActionEvent) { - val project = e.project ?: return - val dialog = ModuleManagerDialog(project) - if (dialog.showAndGet()) { - // Handle OK button press - dialog.saveChanges() - syncGradleProject(project) - } - } - - private fun syncGradleProject(project: Project) { - ExternalSystemUtil.refreshProjects( - ImportSpecBuilder(project, ProjectSystemId("GRADLE")) - .createDirectoriesForEmptyContentRoots() - ) - } - - private class ModuleManagerDialog(project: Project) : DialogWrapper(true) { - private val form = ModuleManagerForm(project) - - init { - init() - title = "Modules Manager" - } - - override fun createCenterPanel(): JComponent { - return form.getScrollPane() - } - - fun saveChanges() { - form.saveChanges() - } - } - -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/rfazi/androiddynamicmodules/ManageModulesAction.kt b/src/main/kotlin/io/github/rfazi/androiddynamicmodules/ManageModulesAction.kt new file mode 100644 index 0000000..a83a138 --- /dev/null +++ b/src/main/kotlin/io/github/rfazi/androiddynamicmodules/ManageModulesAction.kt @@ -0,0 +1,93 @@ +package io.github.rfazi.androiddynamicmodules + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.externalSystem.importing.ImportSpecBuilder +import com.intellij.openapi.externalSystem.util.ExternalSystemUtil +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import io.github.rfazi.androiddynamicmodules.core.ModuleProjectService +import io.github.rfazi.androiddynamicmodules.core.ProjectModulesSnapshot +import org.jetbrains.plugins.gradle.util.GradleConstants +import java.nio.file.Files +import java.nio.file.Path + +class ManageModulesAction : DumbAwareAction() { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun update(event: AnActionEvent) { + val root = event.project?.basePath?.let(Path::of) + event.presentation.isEnabledAndVisible = root != null && ( + Files.isRegularFile(root.resolve("settings.gradle.kts")) || + Files.isRegularFile(root.resolve("settings.gradle")) + ) + } + + override fun actionPerformed(event: AnActionEvent) { + val project = event.project ?: return + val projectRoot = project.basePath?.let(Path::of) + ?: return showError(project, "The project has no local base directory") + + ProgressManager.getInstance().run(object : Task.Modal(project, "Loading Gradle modules", false) { + private lateinit var snapshot: ProjectModulesSnapshot + + override fun run(indicator: ProgressIndicator) { + indicator.isIndeterminate = true + snapshot = ModuleProjectService.load(projectRoot) + } + + override fun onSuccess() { + showModuleDialog(project, snapshot) + } + + override fun onThrowable(error: Throwable) { + showError(project, error.userMessage()) + } + }) + } + + private fun showModuleDialog(project: Project, snapshot: ProjectModulesSnapshot) { + val dialog = ModuleManagerDialog(project, snapshot) + if (!dialog.showAndGet()) return + + runCatching { + ModuleProjectService.plan(snapshot, dialog.selectedStates()) + }.onSuccess { plan -> + if (plan.changes.isEmpty()) { + Messages.showInfoMessage(project, "No module changes to apply.", "Android Dynamic Modules") + return@onSuccess + } + + runCatching { + ModuleDocumentApplier.apply(project, plan) + syncGradleProject(project) + }.onSuccess { + val changedFiles = plan.changes.joinToString("\n") { "• ${it.path.fileName}" } + val warnings = plan.warnings.takeIf(List::isNotEmpty) + ?.joinToString(separator = "\n", prefix = "\n\nWarnings:\n") + .orEmpty() + Messages.showInfoMessage( + project, + "Updated files:\n$changedFiles$warnings\n\nGradle sync has started.", + "Android Dynamic Modules", + ) + }.onFailure { showError(project, it.userMessage()) } + }.onFailure { showError(project, it.userMessage()) } + } + + private fun syncGradleProject(project: Project) { + ExternalSystemUtil.refreshProjects(ImportSpecBuilder(project, GradleConstants.SYSTEM_ID)) + } + + private fun showError(project: Project, message: String) { + Messages.showErrorDialog(project, message, "Android Dynamic Modules") + } + + private fun Throwable.userMessage(): String = message?.takeIf(String::isNotBlank) + ?: this::class.simpleName + ?: "Unexpected error" +} diff --git a/src/main/kotlin/io/github/rfazi/androiddynamicmodules/ModuleDocumentApplier.kt b/src/main/kotlin/io/github/rfazi/androiddynamicmodules/ModuleDocumentApplier.kt new file mode 100644 index 0000000..3a8b990 --- /dev/null +++ b/src/main/kotlin/io/github/rfazi/androiddynamicmodules/ModuleDocumentApplier.kt @@ -0,0 +1,38 @@ +package io.github.rfazi.androiddynamicmodules + +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import io.github.rfazi.androiddynamicmodules.core.ModuleChangePlan + +object ModuleDocumentApplier { + fun apply(project: Project, plan: ModuleChangePlan) { + if (plan.changes.isEmpty()) return + + val fileDocumentManager = FileDocumentManager.getInstance() + val documents = plan.changes.map { change -> + val virtualFile = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(change.path) + ?: error("Cannot find ${change.path} in the IDE file system") + val document = fileDocumentManager.getDocument(virtualFile) + ?: error("Cannot open ${change.path.fileName} as a text document") + require(document.text == change.original) { + "${change.path.fileName} changed after the module list was loaded. Reopen the dialog and try again." + } + Triple(change, virtualFile, document) + } + + WriteCommandAction.runWriteCommandAction(project) { + try { + documents.forEach { (change, _, document) -> document.setText(change.updated) } + } catch (error: Throwable) { + documents.forEach { (change, _, document) -> + if (document.text != change.original) document.setText(change.original) + } + throw error + } + } + + documents.forEach { (_, _, document) -> fileDocumentManager.saveDocument(document) } + } +} diff --git a/src/main/kotlin/io/github/rfazi/androiddynamicmodules/ModuleManagerDialog.kt b/src/main/kotlin/io/github/rfazi/androiddynamicmodules/ModuleManagerDialog.kt new file mode 100644 index 0000000..8b286eb --- /dev/null +++ b/src/main/kotlin/io/github/rfazi/androiddynamicmodules/ModuleManagerDialog.kt @@ -0,0 +1,117 @@ +package io.github.rfazi.androiddynamicmodules + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextField +import com.intellij.util.ui.JBUI +import io.github.rfazi.androiddynamicmodules.core.ProjectModulesSnapshot +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.FlowLayout +import javax.swing.BoxLayout +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.event.DocumentEvent +import javax.swing.event.DocumentListener + +class ModuleManagerDialog( + project: Project, + snapshot: ProjectModulesSnapshot, +) : DialogWrapper(project, true) { + private val panel = ModuleManagerPanel(snapshot) + + init { + title = "Manage Android Modules" + setOKButtonText("Apply and Sync") + init() + } + + override fun createCenterPanel(): JComponent = panel + + fun selectedStates(): Map = panel.selectedStates() +} + +private class ModuleManagerPanel(snapshot: ProjectModulesSnapshot) : JPanel(BorderLayout()) { + private val configuration = snapshot.configuration + private val selectedStates = snapshot.modules.associate { it.path to it.enabled }.toMutableMap() + private val checkBoxes = linkedMapOf() + private val modulesPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + border = JBUI.Borders.empty(4) + } + + init { + border = JBUI.Borders.empty(8) + + val filterField = JBTextField().apply { + emptyText.text = "Filter modules" + document.addDocumentListener(object : DocumentListener { + override fun insertUpdate(event: DocumentEvent) = updateFilter(text) + override fun removeUpdate(event: DocumentEvent) = updateFilter(text) + override fun changedUpdate(event: DocumentEvent) = updateFilter(text) + }) + } + + val header = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(JBLabel("Project: ${snapshot.projectRoot}")) + add(JBLabel("Changes are previewed in memory and applied as one IDE command.")) + add(filterField) + add(JPanel(FlowLayout(FlowLayout.LEADING, 0, 4)).apply { + add(JButton("Enable all").apply { addActionListener { selectAll() } }) + add(JButton("Light preset").apply { addActionListener { selectLightPreset() } }) + }) + } + add(header, BorderLayout.NORTH) + + snapshot.modules + .filterNot { it.path in configuration.hiddenModules } + .sortedBy { it.path } + .forEach { module -> + val required = module.path in configuration.requiredModules + val checkBox = JBCheckBox(module.path, module.enabled || required).apply { + isEnabled = !required + toolTipText = if (required) "Required module" else null + addActionListener { selectedStates[module.path] = isSelected } + } + selectedStates[module.path] = checkBox.isSelected + checkBoxes[module.path] = checkBox + modulesPanel.add(checkBox) + } + + add(JBScrollPane(modulesPanel).apply { + preferredSize = Dimension(560, 420) + border = JBUI.Borders.emptyTop(6) + }, BorderLayout.CENTER) + } + + fun selectedStates(): Map = selectedStates.toMap() + + private fun selectAll() { + checkBoxes.forEach { (path, checkBox) -> + checkBox.isSelected = true + selectedStates[path] = true + } + } + + private fun selectLightPreset() { + checkBoxes.forEach { (path, checkBox) -> + val selected = path in configuration.requiredModules + checkBox.isSelected = selected + selectedStates[path] = selected + } + } + + private fun updateFilter(query: String) { + val normalizedQuery = query.trim().lowercase() + checkBoxes.forEach { (path, checkBox) -> + checkBox.isVisible = normalizedQuery.isEmpty() || normalizedQuery in path.lowercase() + } + modulesPanel.revalidate() + modulesPanel.repaint() + } +} diff --git a/src/main/kotlin/io/github/rfazi/androiddynamicmodules/core/DynamicFeaturesEditor.kt b/src/main/kotlin/io/github/rfazi/androiddynamicmodules/core/DynamicFeaturesEditor.kt new file mode 100644 index 0000000..cbe120f --- /dev/null +++ b/src/main/kotlin/io/github/rfazi/androiddynamicmodules/core/DynamicFeaturesEditor.kt @@ -0,0 +1,92 @@ +package io.github.rfazi.androiddynamicmodules.core + +import java.nio.file.Path + +private data class DynamicFeaturesStatement( + val lineRange: IntRange, + val indentation: String, +) + +class DynamicFeaturesDocument private constructor( + val source: String, + val features: Set, + private val kotlinDsl: Boolean, + private val lines: List, + private val statements: List, +) { + fun render(enabledFeatures: Collection): String { + require(statements.isNotEmpty()) { "No dynamicFeatures declaration was found" } + val normalizedFeatures = enabledFeatures.map(::normalizeModulePath).distinct().sorted() + val firstStatement = statements.first() + val replacementLines = if (kotlinDsl) { + buildList { + add("${firstStatement.indentation}dynamicFeatures.clear()") + add( + if (normalizedFeatures.isEmpty()) { + "${firstStatement.indentation}dynamicFeatures += emptySet()" + } else { + "${firstStatement.indentation}dynamicFeatures += setOf(" + + normalizedFeatures.joinToString(", ") { "\"$it\"" } + + ")" + }, + ) + } + } else { + listOf( + "${firstStatement.indentation}dynamicFeatures = [" + + normalizedFeatures.joinToString(", ") { "\"$it\"" } + + "]", + ) + } + + val replacements = statements.associate { it.lineRange to emptyList() }.toMutableMap() + replacements[firstStatement.lineRange] = replacementLines + return renderWithReplacements(lines, replacements) + } + + companion object { + private val statementStart = Regex( + """^([ \t]*)(?:android\.)?dynamicFeatures\s*(?:\+=|=|\.(?:addAll|clear)\s*\().*$""", + ) + + fun parse(path: Path, source: String): DynamicFeaturesDocument? { + val lines = splitPreservingLineEndings(source) + val statements = mutableListOf() + val featurePaths = linkedSetOf() + var lineIndex = 0 + + while (lineIndex < lines.size) { + val match = statementStart.matchEntire(lines[lineIndex].content) + if (match == null) { + lineIndex++ + continue + } + + var endLine = lineIndex + var balance = balanceDelta(lines[lineIndex].content) + while (balance > 0 && endLine + 1 < lines.size) { + endLine++ + balance += balanceDelta(lines[endLine].content) + } + if (balance != 0) { + throw IllegalArgumentException("Unbalanced dynamicFeatures declaration at line ${lineIndex + 1}") + } + + val statementSource = (lineIndex..endLine).joinToString("\n") { lines[it].content } + extractQuotedStrings(statementSource) + .mapTo(featurePaths, ::normalizeModulePath) + statements += DynamicFeaturesStatement(lineIndex..endLine, match.groupValues[1]) + lineIndex = endLine + 1 + } + + if (statements.isEmpty()) return null + return DynamicFeaturesDocument( + source = source, + features = featurePaths, + kotlinDsl = path.fileName.toString().endsWith(".kts"), + lines = lines, + statements = statements, + ) + } + } +} diff --git a/src/main/kotlin/io/github/rfazi/androiddynamicmodules/core/GradleSettingsParser.kt b/src/main/kotlin/io/github/rfazi/androiddynamicmodules/core/GradleSettingsParser.kt new file mode 100644 index 0000000..e64f60f --- /dev/null +++ b/src/main/kotlin/io/github/rfazi/androiddynamicmodules/core/GradleSettingsParser.kt @@ -0,0 +1,103 @@ +package io.github.rfazi.androiddynamicmodules.core + +private data class IncludeStatement( + val lineRange: IntRange, + val indentation: String, + val disabled: Boolean, + val modules: List, + val trailingComment: String?, +) + +class GradleSettingsDocument private constructor( + val source: String, + val modules: List, + private val lines: List, + private val statements: List, +) { + fun render(moduleStates: Map): String { + val normalizedStates = moduleStates.mapKeys { normalizeModulePath(it.key) } + val replacements = statements.associate { statement -> + statement.lineRange to statement.modules.mapIndexed { index, modulePath -> + buildString { + append(statement.indentation) + val enabled = normalizedStates[modulePath] ?: !statement.disabled + if (!enabled) append("// ") + append("include(\"").append(modulePath).append("\")") + if (index == 0 && statement.trailingComment != null) { + append(' ').append(statement.trailingComment) + } + } + } + } + return renderWithReplacements(lines, replacements) + } + + companion object { + private val includeStart = Regex("""^([ \t]*)(//[ \t]*)?include\b(?!Build\b|Flat\b)(.*)$""") + + fun parse(source: String): GradleSettingsDocument { + val lines = splitPreservingLineEndings(source) + val statements = mutableListOf() + var lineIndex = 0 + + while (lineIndex < lines.size) { + val match = includeStart.matchEntire(lines[lineIndex].content) + if (match == null) { + lineIndex++ + continue + } + + val indentation = match.groupValues[1] + val disabled = match.groupValues[2].isNotEmpty() + val firstLineBody = match.groupValues[3] + var endLine = lineIndex + var balance = balanceDelta(firstLineBody) + val hasParenthesizedArguments = firstLineBody.contains('(') + + while (hasParenthesizedArguments && balance > 0 && endLine + 1 < lines.size) { + endLine++ + balance += balanceDelta(lines[endLine].content) + } + + if (hasParenthesizedArguments && balance != 0) { + throw IllegalArgumentException("Unbalanced include statement at line ${lineIndex + 1}") + } + + val statementSource = buildString { + append("include").append(firstLineBody) + for (currentLine in (lineIndex + 1)..endLine) { + append('\n').append(lines[currentLine].content) + } + } + val modulePaths = extractQuotedStrings(statementSource) + .map(::normalizeModulePath) + .distinct() + + if (modulePaths.isNotEmpty()) { + statements += IncludeStatement( + lineRange = lineIndex..endLine, + indentation = indentation, + disabled = disabled, + modules = modulePaths, + trailingComment = findTrailingComment(firstLineBody), + ) + } + lineIndex = endLine + 1 + } + + val moduleStates = linkedMapOf() + statements.forEach { statement -> + statement.modules.forEach { modulePath -> + moduleStates[modulePath] = moduleStates[modulePath] != false && !statement.disabled + } + } + + return GradleSettingsDocument( + source = source, + modules = moduleStates.map { (path, enabled) -> ModuleState(path, enabled) }, + lines = lines, + statements = statements, + ) + } + } +} diff --git a/src/main/kotlin/io/github/rfazi/androiddynamicmodules/core/ModuleConfigurationLoader.kt b/src/main/kotlin/io/github/rfazi/androiddynamicmodules/core/ModuleConfigurationLoader.kt new file mode 100644 index 0000000..a0b0ae9 --- /dev/null +++ b/src/main/kotlin/io/github/rfazi/androiddynamicmodules/core/ModuleConfigurationLoader.kt @@ -0,0 +1,44 @@ +package io.github.rfazi.androiddynamicmodules.core + +import java.nio.file.Files +import java.nio.file.Path +import java.util.Properties + +object ModuleConfigurationLoader { + const val FILE_NAME = ".android-dynamic-modules.properties" + + fun load(projectRoot: Path): ModuleConfiguration { + val configurationPath = projectRoot.resolve(FILE_NAME) + if (!Files.exists(configurationPath)) return ModuleConfiguration() + + val properties = Properties().apply { + Files.newBufferedReader(configurationPath).use(::load) + } + val applicationModule = properties.getProperty("applicationModule") + ?.takeIf(String::isNotBlank) + ?.let(::normalizeModulePath) + ?: ":app" + + return ModuleConfiguration( + applicationModule = applicationModule, + applicationModuleDirectory = properties.getProperty("applicationModuleDirectory") + ?.trim() + ?.takeIf(String::isNotEmpty), + requiredModules = parseModulePaths(properties.getProperty("requiredModules")) + .ifEmpty { setOf(applicationModule) }, + hiddenModules = parseModulePaths(properties.getProperty("hiddenModules")), + dynamicFeaturePrefixes = parseList(properties.getProperty("dynamicFeaturePrefixes")) + .ifEmpty { setOf("df_") }, + ) + } + + private fun parseModulePaths(value: String?): Set = + parseList(value).mapTo(linkedSetOf(), ::normalizeModulePath) + + private fun parseList(value: String?): Set = value + ?.split(',') + ?.map(String::trim) + ?.filter(String::isNotEmpty) + ?.toCollection(linkedSetOf()) + .orEmpty() +} diff --git a/src/main/kotlin/io/github/rfazi/androiddynamicmodules/core/ModuleModel.kt b/src/main/kotlin/io/github/rfazi/androiddynamicmodules/core/ModuleModel.kt new file mode 100644 index 0000000..b19eb77 --- /dev/null +++ b/src/main/kotlin/io/github/rfazi/androiddynamicmodules/core/ModuleModel.kt @@ -0,0 +1,57 @@ +package io.github.rfazi.androiddynamicmodules.core + +import java.nio.file.Path + +data class ModuleState( + val path: String, + val enabled: Boolean, +) + +data class ModuleConfiguration( + val applicationModule: String = ":app", + val applicationModuleDirectory: String? = null, + val requiredModules: Set = setOf(":app"), + val hiddenModules: Set = emptySet(), + val dynamicFeaturePrefixes: Set = setOf("df_"), +) { + fun isDynamicFeature(modulePath: String): Boolean { + val name = normalizeModulePath(modulePath).substringAfterLast(':') + return dynamicFeaturePrefixes.any(name::startsWith) + } +} + +data class ProjectModulesSnapshot( + val projectRoot: Path, + val settingsPath: Path, + val settingsDocument: GradleSettingsDocument, + val configuration: ModuleConfiguration, + val applicationBuildPath: Path?, + val dynamicFeaturesDocument: DynamicFeaturesDocument?, +) { + val modules: List = settingsDocument.modules + + val dynamicFeatureModules: Set = buildSet { + addAll(dynamicFeaturesDocument?.features.orEmpty()) + modules.map { it.path }.filterTo(this, configuration::isDynamicFeature) + } +} + +data class TextChange( + val path: Path, + val original: String, + val updated: String, +) + +data class ModuleChangePlan( + val changes: List, + val warnings: List = emptyList(), +) + +fun normalizeModulePath(rawPath: String): String { + val segments = rawPath.trim().split(':').filter(String::isNotBlank) + require(segments.isNotEmpty()) { "Invalid empty Gradle module path" } + require(segments.all { segment -> + segment != "." && segment != ".." && '/' !in segment && '\\' !in segment + }) { "Invalid Gradle module path: $rawPath" } + return segments.joinToString(separator = ":", prefix = ":") +} diff --git a/src/main/kotlin/io/github/rfazi/androiddynamicmodules/core/ModuleProjectService.kt b/src/main/kotlin/io/github/rfazi/androiddynamicmodules/core/ModuleProjectService.kt new file mode 100644 index 0000000..cde2f06 --- /dev/null +++ b/src/main/kotlin/io/github/rfazi/androiddynamicmodules/core/ModuleProjectService.kt @@ -0,0 +1,124 @@ +package io.github.rfazi.androiddynamicmodules.core + +import java.nio.file.Files +import java.nio.file.Path + +object ModuleProjectService { + fun load(projectRoot: Path): ProjectModulesSnapshot { + val normalizedRoot = projectRoot.toAbsolutePath().normalize() + require(Files.isDirectory(normalizedRoot)) { "Project directory does not exist: $normalizedRoot" } + + val settingsPath = resolveSingleFile( + normalizedRoot.resolve("settings.gradle.kts"), + normalizedRoot.resolve("settings.gradle"), + "Gradle settings file", + required = true, + )!! + val settingsSource = Files.readString(settingsPath) + val settingsDocument = GradleSettingsDocument.parse(settingsSource) + require(settingsDocument.modules.isNotEmpty()) { + "No literal include(...) module declarations were found in ${settingsPath.fileName}" + } + + val configuration = ModuleConfigurationLoader.load(normalizedRoot) + val applicationDirectory = resolveApplicationDirectory(normalizedRoot, configuration) + val applicationBuildPath = resolveSingleFile( + applicationDirectory.resolve("build.gradle.kts"), + applicationDirectory.resolve("build.gradle"), + "application module build file", + required = false, + ) + val dynamicFeaturesDocument = applicationBuildPath?.let { path -> + DynamicFeaturesDocument.parse(path, Files.readString(path)) + } + + return ProjectModulesSnapshot( + projectRoot = normalizedRoot, + settingsPath = settingsPath, + settingsDocument = settingsDocument, + configuration = configuration, + applicationBuildPath = applicationBuildPath, + dynamicFeaturesDocument = dynamicFeaturesDocument, + ) + } + + fun plan( + snapshot: ProjectModulesSnapshot, + requestedStates: Map, + ): ModuleChangePlan { + val knownModules = snapshot.modules.mapTo(linkedSetOf()) { it.path } + val unknownModules = requestedStates.keys.map(::normalizeModulePath).filterNot(knownModules::contains) + require(unknownModules.isEmpty()) { "Unknown modules: ${unknownModules.joinToString()}" } + + val desiredStates = snapshot.modules.associate { module -> + module.path to (requestedStates[module.path] ?: module.enabled) + }.toMutableMap() + snapshot.configuration.requiredModules.forEach { requiredModule -> + if (requiredModule in knownModules) desiredStates[requiredModule] = true + } + + val changes = mutableListOf() + val updatedSettings = snapshot.settingsDocument.render(desiredStates) + if (updatedSettings != snapshot.settingsDocument.source) { + changes += TextChange(snapshot.settingsPath, snapshot.settingsDocument.source, updatedSettings) + } + + val dynamicFeatureModules = snapshot.dynamicFeatureModules.intersect(knownModules) + if (dynamicFeatureModules.isNotEmpty()) { + val applicationBuildPath = requireNotNull(snapshot.applicationBuildPath) { + "Dynamic feature modules were found, but the application build file does not exist" + } + val dynamicFeaturesDocument = requireNotNull(snapshot.dynamicFeaturesDocument) { + "Dynamic feature modules were found, but no dynamicFeatures declaration exists in ${applicationBuildPath.fileName}" + } + val enabledDynamicFeatures = dynamicFeatureModules.filter { desiredStates[it] == true } + val updatedApplicationBuild = dynamicFeaturesDocument.render(enabledDynamicFeatures) + if (updatedApplicationBuild != dynamicFeaturesDocument.source) { + changes += TextChange( + applicationBuildPath, + dynamicFeaturesDocument.source, + updatedApplicationBuild, + ) + } + } + + val warnings = buildList { + val missingRequired = snapshot.configuration.requiredModules - knownModules + if (missingRequired.isNotEmpty()) { + add("Configured required modules were not found: ${missingRequired.joinToString()}") + } + } + return ModuleChangePlan(changes = changes, warnings = warnings) + } + + private fun resolveApplicationDirectory( + projectRoot: Path, + configuration: ModuleConfiguration, + ): Path { + val relativeDirectory = configuration.applicationModuleDirectory?.let(Path::of) + ?: Path.of( + configuration.applicationModule + .removePrefix(":") + .replace(':', '/'), + ) + val resolved = projectRoot.resolve(relativeDirectory).normalize() + require(resolved.startsWith(projectRoot)) { + "applicationModuleDirectory must stay inside the project directory" + } + return resolved + } + + private fun resolveSingleFile( + first: Path, + second: Path, + label: String, + required: Boolean, + ): Path? { + val existing = listOf(first, second).filter(Files::isRegularFile) + require(existing.size <= 1) { + "Both ${first.fileName} and ${second.fileName} exist; cannot choose the $label safely" + } + if (required) require(existing.isNotEmpty()) { "Missing $label" } + return existing.singleOrNull() + } +} diff --git a/src/main/kotlin/io/github/rfazi/androiddynamicmodules/core/TextParsing.kt b/src/main/kotlin/io/github/rfazi/androiddynamicmodules/core/TextParsing.kt new file mode 100644 index 0000000..edd8dff --- /dev/null +++ b/src/main/kotlin/io/github/rfazi/androiddynamicmodules/core/TextParsing.kt @@ -0,0 +1,148 @@ +package io.github.rfazi.androiddynamicmodules.core + +internal data class SourceLine( + val content: String, + val ending: String, +) + +internal fun splitPreservingLineEndings(source: String): List { + if (source.isEmpty()) return listOf(SourceLine("", "")) + + val lines = mutableListOf() + var lineStart = 0 + var index = 0 + while (index < source.length) { + when (source[index]) { + '\r' -> { + val ending = if (index + 1 < source.length && source[index + 1] == '\n') "\r\n" else "\r" + lines += SourceLine(source.substring(lineStart, index), ending) + index += ending.length + lineStart = index + } + + '\n' -> { + lines += SourceLine(source.substring(lineStart, index), "\n") + index++ + lineStart = index + } + + else -> index++ + } + } + + if (lineStart < source.length) { + lines += SourceLine(source.substring(lineStart), "") + } + return lines +} + +internal fun extractQuotedStrings(source: String): List { + val values = mutableListOf() + var index = 0 + var quote: Char? = null + var escaped = false + val current = StringBuilder() + + while (index < source.length) { + val char = source[index] + if (quote == null) { + if (char == '/' && index + 1 < source.length && source[index + 1] == '/') { + index = source.indexOf('\n', index).takeIf { it >= 0 } ?: source.length + continue + } + if (char == '\'' || char == '"') { + quote = char + current.clear() + } + } else if (escaped) { + current.append(char) + escaped = false + } else if (char == '\\') { + escaped = true + } else if (char == quote) { + values += current.toString() + quote = null + } else { + current.append(char) + } + index++ + } + return values +} + +internal fun balanceDelta(source: String): Int { + var delta = 0 + var quote: Char? = null + var escaped = false + var index = 0 + while (index < source.length) { + val char = source[index] + if (quote == null) { + if (char == '/' && index + 1 < source.length && source[index + 1] == '/') break + when (char) { + '\'', '"' -> quote = char + '(', '[', '{' -> delta++ + ')', ']', '}' -> delta-- + } + } else if (escaped) { + escaped = false + } else if (char == '\\') { + escaped = true + } else if (char == quote) { + quote = null + } + index++ + } + return delta +} + +internal fun findTrailingComment(source: String): String? { + var quote: Char? = null + var escaped = false + var index = 0 + while (index + 1 < source.length) { + val char = source[index] + if (quote == null) { + if (char == '/' && source[index + 1] == '/') return source.substring(index).trimEnd() + if (char == '\'' || char == '"') quote = char + } else if (escaped) { + escaped = false + } else if (char == '\\') { + escaped = true + } else if (char == quote) { + quote = null + } + index++ + } + return null +} + +internal fun renderWithReplacements( + lines: List, + replacements: Map>, +): String { + val result = StringBuilder() + var lineIndex = 0 + val replacementsByStart = replacements.entries.associateBy { it.key.first } + + while (lineIndex < lines.size) { + val replacement = replacementsByStart[lineIndex] + if (replacement == null) { + result.append(lines[lineIndex].content).append(lines[lineIndex].ending) + lineIndex++ + continue + } + + val lastOriginalLine = lines[replacement.key.last] + replacement.value.forEachIndexed { replacementIndex, content -> + result.append(content) + if (replacementIndex < replacement.value.lastIndex) { + result.append(lines[replacement.key.first].ending.ifEmpty { "\n" }) + } else { + result.append(lastOriginalLine.ending) + } + } + lineIndex = replacement.key.last + 1 + } + return result.toString() +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 584615f..4d7c458 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1,35 +1,29 @@ - - - com.example.ADP_Github + io.github.rfazi.androiddynamicmodules + Android Dynamic Modules + Ruben Fazi - - ADP_Github - - - YourCompany - - +

+ Enable or disable Gradle modules from the IDE and keep Android dynamic feature declarations + aligned with the selected modules. +

+

+ The plugin supports Gradle Groovy and Kotlin DSL projects, validates all edits before writing, + applies changes as one IDE command, and starts a Gradle sync only when files changed. +

+ ]]> - com.intellij.modules.platform + org.jetbrains.plugins.gradle - - - - - + -
\ No newline at end of file + diff --git a/src/test/kotlin/io/github/rfazi/androiddynamicmodules/core/DynamicFeaturesDocumentTest.kt b/src/test/kotlin/io/github/rfazi/androiddynamicmodules/core/DynamicFeaturesDocumentTest.kt new file mode 100644 index 0000000..06d9472 --- /dev/null +++ b/src/test/kotlin/io/github/rfazi/androiddynamicmodules/core/DynamicFeaturesDocumentTest.kt @@ -0,0 +1,68 @@ +package io.github.rfazi.androiddynamicmodules.core + +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class DynamicFeaturesDocumentTest { + @Test + fun `rewrites Groovy dynamic features`() { + val source = """ + android { + dynamicFeatures = [":df_search", ":df_payments"] + } + """.trimIndent() + "\n" + val document = assertNotNull(DynamicFeaturesDocument.parse(Path.of("build.gradle"), source)) + + val rendered = document.render(listOf(":df_payments")) + + assertEquals( + """ + android { + dynamicFeatures = [":df_payments"] + } + """.trimIndent() + "\n", + rendered, + ) + } + + @Test + fun `consolidates Kotlin dynamic feature declarations`() { + val source = """ + android { + dynamicFeatures += setOf(":df_search") + dynamicFeatures += setOf( + ":df_payments", + ) + } + """.trimIndent() + "\n" + val document = assertNotNull(DynamicFeaturesDocument.parse(Path.of("build.gradle.kts"), source)) + + val rendered = document.render(listOf(":df_payments", ":df_search")) + + assertEquals( + """ + android { + dynamicFeatures.clear() + dynamicFeatures += setOf(":df_payments", ":df_search") + } + """.trimIndent() + "\n", + rendered, + ) + } + + @Test + fun `keeps an empty Kotlin declaration editable on the next run`() { + val source = "android {\n dynamicFeatures += setOf(\":df_search\")\n}\n" + val firstPass = assertNotNull(DynamicFeaturesDocument.parse(Path.of("build.gradle.kts"), source)) + .render(emptyList()) + val secondPass = assertNotNull(DynamicFeaturesDocument.parse(Path.of("build.gradle.kts"), firstPass)) + .render(listOf(":df_search")) + + assertEquals( + "android {\n dynamicFeatures.clear()\n dynamicFeatures += setOf(\":df_search\")\n}\n", + secondPass, + ) + } +} diff --git a/src/test/kotlin/io/github/rfazi/androiddynamicmodules/core/GradleSettingsDocumentTest.kt b/src/test/kotlin/io/github/rfazi/androiddynamicmodules/core/GradleSettingsDocumentTest.kt new file mode 100644 index 0000000..d75832f --- /dev/null +++ b/src/test/kotlin/io/github/rfazi/androiddynamicmodules/core/GradleSettingsDocumentTest.kt @@ -0,0 +1,65 @@ +package io.github.rfazi.androiddynamicmodules.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class GradleSettingsDocumentTest { + @Test + fun `parses Kotlin and Groovy include forms without matching includeBuild`() { + val source = """ + pluginManagement { includeBuild("build-logic") } + include(":app", ":feature", ":feature-auth") + // include ':optional' // local-only + include ":legacy" + """.trimIndent() + + val document = GradleSettingsDocument.parse(source) + val modules = document.modules.associate { it.path to it.enabled } + + assertEquals(setOf(":app", ":feature", ":feature-auth", ":optional", ":legacy"), modules.keys) + assertTrue(modules.getValue(":app")) + assertFalse(modules.getValue(":optional")) + } + + @Test + fun `renders each module independently and preserves trailing comments`() { + val source = """ + include(":app", ":feature", ":feature-auth") + // include(":optional") // local-only + includeBuild("build-logic") + """.trimIndent() + "\n" + val document = GradleSettingsDocument.parse(source) + + val rendered = document.render( + mapOf( + ":app" to true, + ":feature" to false, + ":feature-auth" to true, + ":optional" to true, + ), + ) + + assertEquals( + """ + include(":app") + // include(":feature") + include(":feature-auth") + include(":optional") // local-only + includeBuild("build-logic") + """.trimIndent() + "\n", + rendered, + ) + } + + @Test + fun `supports multiline includes and CRLF line endings`() { + val source = "include(\r\n \":app\",\r\n \":feature:login\",\r\n)\r\n" + val document = GradleSettingsDocument.parse(source) + + val rendered = document.render(mapOf(":app" to true, ":feature:login" to false)) + + assertEquals("include(\":app\")\r\n// include(\":feature:login\")\r\n", rendered) + } +} diff --git a/src/test/kotlin/io/github/rfazi/androiddynamicmodules/core/ModuleProjectServiceTest.kt b/src/test/kotlin/io/github/rfazi/androiddynamicmodules/core/ModuleProjectServiceTest.kt new file mode 100644 index 0000000..67412ca --- /dev/null +++ b/src/test/kotlin/io/github/rfazi/androiddynamicmodules/core/ModuleProjectServiceTest.kt @@ -0,0 +1,70 @@ +package io.github.rfazi.androiddynamicmodules.core + +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class ModuleProjectServiceTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `plans settings and Kotlin dynamic feature changes without touching disk`() { + val root = temporaryFolder.newFolder("project").toPath() + val settings = root.resolve("settings.gradle.kts") + val appBuild = root.resolve("app/build.gradle.kts") + Files.createDirectories(appBuild.parent) + Files.writeString(settings, "include(\":app\", \":optional\", \":df_search\")\n") + Files.writeString( + appBuild, + "android {\n dynamicFeatures += setOf(\":df_search\")\n}\n", + ) + + val snapshot = ModuleProjectService.load(root) + val plan = ModuleProjectService.plan( + snapshot, + mapOf(":app" to false, ":optional" to false, ":df_search" to false), + ) + + assertEquals(2, plan.changes.size) + assertTrue(plan.changes.first { it.path == settings }.updated.contains("include(\":app\")")) + assertTrue(plan.changes.first { it.path == settings }.updated.contains("// include(\":optional\")")) + assertTrue(plan.changes.first { it.path == appBuild }.updated.contains("dynamicFeatures.clear()")) + assertEquals("include(\":app\", \":optional\", \":df_search\")\n", Files.readString(settings)) + } + + @Test + fun `fails closed when dynamic features exist without an application build file`() { + val root = temporaryFolder.newFolder("missing-app").toPath() + val settings = root.resolve("settings.gradle.kts") + Files.writeString(settings, "include(\":app\", \":df_search\")\n") + val snapshot = ModuleProjectService.load(root) + + assertFailsWith { + ModuleProjectService.plan(snapshot, mapOf(":df_search" to false)) + } + assertEquals("include(\":app\", \":df_search\")\n", Files.readString(settings)) + } + + @Test + fun `loads project configuration and keeps required modules enabled`() { + val root = temporaryFolder.newFolder("configured").toPath() + Files.writeString(root.resolve("settings.gradle"), "include ':base', ':optional'\n") + Files.writeString( + root.resolve(ModuleConfigurationLoader.FILE_NAME), + "applicationModule=:base\nrequiredModules=:base\nhiddenModules=:optional\ndynamicFeaturePrefixes=feature_\n", + ) + + val snapshot = ModuleProjectService.load(root) + val plan = ModuleProjectService.plan(snapshot, mapOf(":base" to false, ":optional" to true)) + val updatedSettings = plan.changes.single().updated + + assertTrue(updatedSettings.contains("include(\":base\")")) + assertTrue(updatedSettings.contains("include(\":optional\")")) + assertEquals(setOf(":optional"), snapshot.configuration.hiddenModules) + } +}