feat: Set patch options via CLI (#336)

BREAKING CHANGE: This commit changes various CLI options and removes the `options.json` file. Instead, patch options can now be passed via CLI options
This commit is contained in:
oSumAtrIX 2024-08-13 00:12:45 +04:00 committed by GitHub
parent 54ae01cd76
commit 23002434b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 409 additions and 203 deletions

View File

@ -13,10 +13,12 @@ java -jar revanced-cli.jar -h
## 📃 List patches
```bash
java -jar revanced-cli.jar list-patches --with-descriptions --with-packages --with-versions --with-options --with-universal-patches revanced-patches.rvp
java -jar revanced-cli.jar list-patches --with-packages --with-versions --with-options revanced-patches.rvp
```
## 💉 Patch an app with the default list of patches
## 💉 Patch an app
To patch an app using the default list of patches, use the `patch` command:
```bash
java -jar revanced-cli.jar patch -b revanced-patches.rvp input.apk
@ -28,22 +30,37 @@ You can also use multiple patch bundles:
java -jar revanced-cli.jar patch -b revanced-patches.rvp -b another-patches.rvp input.apk
```
To manually include or exclude patches, use the options `-i` and `-e`.
Keep in mind the name of the patch must be an exact match.
You can also use the options `--ii` and `--ie` to include or exclude patches by their index
if two patches have the same name.
To know the indices of patches, use the option `--with-indices` when listing patches:
To change the default set of used patches, use the option `-i` or `-e` to use or disuse specific patches.
You can use the `list-patches` command to see which patches are used by default.
To only use specific patches, you can use the option `--exclusive` combined with `-i`.
Remember that the options `-i` and `-e` match the patch's name exactly. Here is an example:
```bash
java -jar revanced-cli.jar list-patches --with-indices revanced-patches.rvp
java -jar revanced-cli.jar patch -b revanced-patches.rvp --exclusive -i "Patch name" -i "Another patch name" input.apk
```
Then you can use the indices to include or exclude patches:
You can also use the options `--ii` and `--ie` to use or disuse patches by their index.
This is useful, if two patches happen to have the same name.
To know the indices of patches, use the command `list-patches`:
```bash
java -jar revanced-cli.jar list-patches revanced-patches.rvp
```
Then you can use the indices to use or disuse patches:
```bash
java -jar revanced-cli.jar patch -b revanced-patches.rvp --ii 123 --ie 456 input.apk
```
You can combine the option `-i`, `-e`, `--ii`, `--ie` and `--exclusive`. Here is an example:
```bash
java -jar revanced-cli.jar patch -b revanced-patches.rvp --exclusive -i "Patch name" --ii 123 input.apk
```
> [!TIP]
> You can use the option `-d` to automatically install the patched app after patching.
> Make sure ADB is working:
@ -62,7 +79,61 @@ java -jar revanced-cli.jar patch -b revanced-patches.rvp --ii 123 --ie 456 input
> adb install input.apk
> ```
## 📦 Install an app manually
Patches can have options you can set using the option `-O` alongside the option to include the patch by name or index.
To know the options of a patch, use the option `--with-options` when listing patches:
```bash
java -jar revanced-cli.jar list-patches --with-options revanced-patches.rvp
```
Each patch can have multiple options. You can set them using the option `-O`.
For example, to set the options for the patch with the name `Patch name`
with the key `key1` and `key2` to `value1` and `value2` respectively, use the following command:
```bash
java -jar revanced-cli.jar patch -b revanced-patches.rvp -i "Patch name" -Okey1=value1 -Okey2=value2 input.apk
```
If you want to set a value to `null`, you can omit the value:
```bash
java -jar revanced-cli.jar patch -b revanced-patches.rvp -i "Patch name" -Okey1 input.apk
```
> [!WARNING]
> Option values are usually typed. If you set a value with the wrong type, the patch can fail.
> Option value types can be seen when listing patches with the option `--with-options`.
>
> Example option values:
>
> - String: `string`
> - Boolean: `true`, `false`
> - Integer: `123`
> - Double: `1.0`
> - Float: `1.0f`
> - Long: `1234567890`, `1L`
> - List: `[item1,item2,item3]`
> - List of type `Any`: `[item1,123,true,1.0]`
> - Empty list of type `Any`: `[]`
> - Typed empty list: `int[]`
> - Typed and nested empty list: `[int[]]`
> - List with null value and two empty strings: `[null,\'\',\"\"]`
>
> Quotes and commas escaped in strings (`\"`, `\'`, `\,`) are parsed as part of the string.
> List items are recursively parsed, so you can escape values in lists:
>
> - Escaped integer as a string: `[\'123\']`
> - Escaped boolean as a string: `[\'true\']`
> - Escaped list as a string: `[\'[item1,item2]\']`
> - Escaped null value as a string: `[\'null\']`
> - List with an integer, an integer as a string and a string with a comma, and an escaped list: [`123,\'123\',str\,ing`,`\'[]\'`]
>
> Example command with an escaped integer as a string:
>
> ```bash
> java -jar revanced-cli.jar -b revanced-patches.rvp -i "Patch name" -OstringKey=\'1\' input.apk
> ```
## 📦 Install an app manually
```bash
java -jar revanced-cli.jar utility install -a input.apk

View File

@ -0,0 +1,105 @@
package app.revanced.cli.command
import picocli.CommandLine
class OptionKeyConverter : CommandLine.ITypeConverter<String> {
override fun convert(value: String): String = value
}
class OptionValueConverter : CommandLine.ITypeConverter<Any?> {
override fun convert(value: String?): Any? {
value ?: return null
return when {
value.startsWith("[") && value.endsWith("]") -> {
val innerValue = value.substring(1, value.length - 1)
buildList {
var nestLevel = 0
var insideQuote = false
var escaped = false
val item = buildString {
for (char in innerValue) {
when (char) {
'\\' -> {
if (escaped || nestLevel != 0) {
append(char)
}
escaped = !escaped
}
'"', '\'' -> {
if (!escaped) {
insideQuote = !insideQuote
} else {
escaped = false
}
append(char)
}
'[' -> {
if (!insideQuote) {
nestLevel++
}
append(char)
}
']' -> {
if (!insideQuote) {
nestLevel--
if (nestLevel == -1) {
return value
}
}
append(char)
}
',' -> if (nestLevel == 0) {
if (insideQuote) {
append(char)
} else {
add(convert(toString()))
setLength(0)
}
} else {
append(char)
}
else -> append(char)
}
}
}
if (item.isNotEmpty()) {
add(convert(item))
}
}
}
value.startsWith("\"") && value.endsWith("\"") -> value.substring(1, value.length - 1)
value.startsWith("'") && value.endsWith("'") -> value.substring(1, value.length - 1)
value.endsWith("f") -> value.dropLast(1).toFloat()
value.endsWith("L") -> value.dropLast(1).toLong()
value.equals("true", ignoreCase = true) -> true
value.equals("false", ignoreCase = true) -> false
value.toIntOrNull() != null -> value.toInt()
value.toLongOrNull() != null -> value.toLong()
value.toDoubleOrNull() != null -> value.toDouble()
value.toFloatOrNull() != null -> value.toFloat()
value == "null" -> null
value == "int[]" -> emptyList<Int>()
value == "long[]" -> emptyList<Long>()
value == "double[]" -> emptyList<Double>()
value == "float[]" -> emptyList<Float>()
value == "boolean[]" -> emptyList<Boolean>()
value == "string[]" -> emptyList<String>()
else -> value
}
}
}

View File

@ -1,8 +1,8 @@
package app.revanced.cli.command
import app.revanced.library.PackageName
import app.revanced.library.PatchUtils
import app.revanced.library.VersionMap
import app.revanced.library.mostCommonCompatibleVersions
import app.revanced.patcher.patch.loadPatchesFromJar
import picocli.CommandLine
import java.io.File
@ -12,11 +12,11 @@ import java.util.logging.Logger
name = "list-versions",
description = [
"List the most common compatible versions of apps that are compatible " +
"with the patches in the supplied patch bundles.",
"with the patches in the supplied patch bundles.",
],
)
internal class ListCompatibleVersions : Runnable {
private val logger = Logger.getLogger(ListCompatibleVersions::class.java.name)
private val logger = Logger.getLogger(this::class.java.name)
@CommandLine.Parameters(
description = ["Paths to patch bundles."],
@ -58,8 +58,7 @@ internal class ListCompatibleVersions : Runnable {
val patches = loadPatchesFromJar(patchBundles)
PatchUtils.getMostCommonCompatibleVersions(
patches,
patches.mostCommonCompatibleVersions(
packageNames,
countUnusedPatches,
).entries.joinToString("\n", transform = ::buildString).let(logger::info)

View File

@ -14,7 +14,7 @@ import app.revanced.patcher.patch.Option as PatchOption
description = ["List patches from supplied patch bundles."],
)
internal object ListPatchesCommand : Runnable {
private val logger = Logger.getLogger(ListPatchesCommand::class.java.name)
private val logger = Logger.getLogger(this::class.java.name)
@Parameters(
description = ["Paths to patch bundles."],
@ -95,9 +95,11 @@ internal object ListPatchesCommand : Runnable {
} ?: append("Key: $key")
values?.let { values ->
appendLine("\nValid values:")
appendLine("\nPossible values:")
append(values.map { "${it.value} (${it.key})" }.joinToString("\n").prependIndent("\t"))
}
append("\nType: $type")
}
fun IndexedValue<Patch<*>>.buildString() =

View File

@ -34,7 +34,6 @@ private object CLIVersionProvider : IVersionProvider {
versionProvider = CLIVersionProvider::class,
subcommands = [
PatchCommand::class,
OptionsCommand::class,
ListPatchesCommand::class,
ListCompatibleVersions::class,
UtilityCommand::class,

View File

@ -1,62 +0,0 @@
package app.revanced.cli.command
import app.revanced.library.Options
import app.revanced.library.Options.setOptions
import app.revanced.patcher.patch.loadPatchesFromJar
import picocli.CommandLine
import picocli.CommandLine.Help.Visibility.ALWAYS
import java.io.File
import java.util.logging.Logger
@CommandLine.Command(
name = "options",
description = ["Generate options file from patches."],
)
internal object OptionsCommand : Runnable {
private val logger = Logger.getLogger(OptionsCommand::class.java.name)
@CommandLine.Parameters(
description = ["Paths to patch bundles."],
arity = "1..*",
)
private lateinit var patchBundles: Set<File>
@CommandLine.Option(
names = ["-p", "--path"],
description = ["Path to patch options JSON file."],
showDefaultValue = ALWAYS,
)
private var filePath: File = File("options.json")
@CommandLine.Option(
names = ["-o", "--overwrite"],
description = ["Overwrite existing options file."],
showDefaultValue = ALWAYS,
)
private var overwrite: Boolean = false
@CommandLine.Option(
names = ["-u", "--update"],
description = ["Update existing options by adding missing and removing non-existent options."],
showDefaultValue = ALWAYS,
)
private var update: Boolean = false
override fun run() =
try {
loadPatchesFromJar(patchBundles).let { patches ->
val exists = filePath.exists()
if (!exists || overwrite) {
if (exists && update) patches.setOptions(filePath)
Options.serialize(patches, prettyPrint = true).let(filePath::writeText)
} else {
throw OptionsFileAlreadyExistsException()
}
}
} catch (ex: OptionsFileAlreadyExistsException) {
logger.severe("Options file already exists, use --overwrite to override it")
}
class OptionsFileAlreadyExistsException : Exception()
}

View File

@ -2,15 +2,15 @@ package app.revanced.cli.command
import app.revanced.library.ApkUtils
import app.revanced.library.ApkUtils.applyTo
import app.revanced.library.Options
import app.revanced.library.Options.setOptions
import app.revanced.library.installation.installer.*
import app.revanced.library.setOptions
import app.revanced.patcher.Patcher
import app.revanced.patcher.PatcherConfig
import app.revanced.patcher.patch.Patch
import app.revanced.patcher.patch.loadPatchesFromJar
import kotlinx.coroutines.runBlocking
import picocli.CommandLine
import picocli.CommandLine.ArgGroup
import picocli.CommandLine.Help.Visibility.ALWAYS
import picocli.CommandLine.Model.CommandSpec
import picocli.CommandLine.Spec
@ -24,44 +24,71 @@ import java.util.logging.Logger
description = ["Patch an APK file."],
)
internal object PatchCommand : Runnable {
private val logger = Logger.getLogger(PatchCommand::class.java.name)
private val logger = Logger.getLogger(this::class.java.name)
@Spec
lateinit var spec: CommandSpec // injected by picocli
private lateinit var spec: CommandSpec
private lateinit var apk: File
@ArgGroup(multiplicity = "0..*")
private lateinit var selection: Set<Selection>
private var patchBundles = emptySet<File>()
internal class Selection {
@ArgGroup(exclusive = false, multiplicity = "1")
internal var include: IncludeSelection? = null
@CommandLine.Option(
names = ["-i", "--include"],
description = ["List of patches to include."],
)
private var includedPatches = hashSetOf<String>()
internal class IncludeSelection {
@ArgGroup(multiplicity = "1")
internal lateinit var selector: IncludeSelector
@CommandLine.Option(
names = ["--ii"],
description = ["List of patches to include by their index in relation to the supplied patch bundles."],
)
private var includedPatchesByIndex = arrayOf<Int>()
internal class IncludeSelector {
@CommandLine.Option(
names = ["-i", "--include"],
description = ["The name of the patch."],
required = true,
)
internal var name: String? = null
@CommandLine.Option(
names = ["-e", "--exclude"],
description = ["List of patches to exclude."],
)
private var excludedPatches = hashSetOf<String>()
@CommandLine.Option(
names = ["--ii"],
description = ["The index of the patch in the combined list of all supplied patch bundles."],
required = true,
)
internal var index: Int? = null
}
@CommandLine.Option(
names = ["--ei"],
description = ["List of patches to exclude by their index in relation to the supplied patch bundles."],
)
private var excludedPatchesByIndex = arrayOf<Int>()
@CommandLine.Option(
names = ["-O", "--options"],
description = ["The option values keyed by the option keys."],
mapFallbackValue = CommandLine.Option.NULL_VALUE,
converter = [OptionKeyConverter::class, OptionValueConverter::class],
)
internal var options = mutableMapOf<String, Any?>()
}
@CommandLine.Option(
names = ["--options"],
description = ["Path to patch options JSON file."],
)
private var optionsFile: File? = null
@ArgGroup(exclusive = false, multiplicity = "1")
internal var exclude: ExcludeSelection? = null
internal class ExcludeSelection {
@ArgGroup(multiplicity = "1")
internal lateinit var selector: ExcludeSelector
internal class ExcludeSelector {
@CommandLine.Option(
names = ["-e", "--exclude"],
description = ["The name of the patch."],
required = true,
)
internal var name: String? = null
@CommandLine.Option(
names = ["--ie"],
description = ["The index of the patch in the combined list of all supplied patch bundles."],
required = true,
)
internal var index: Int? = null
}
}
}
@CommandLine.Option(
names = ["--exclusive"],
@ -141,7 +168,7 @@ internal object PatchCommand : Runnable {
@CommandLine.Option(
names = ["-t", "--temporary-files-path"],
description = ["Path to temporary files directory."],
description = ["Path to store temporary files."],
)
private var temporaryFilesPath: File? = null
@ -154,13 +181,6 @@ internal object PatchCommand : Runnable {
)
private var purge: Boolean = false
@CommandLine.Option(
names = ["-w", "--warn"],
description = ["Warn if a patch can not be found in the supplied patch bundles."],
showDefaultValue = ALWAYS,
)
private var warn: Boolean = false
@CommandLine.Parameters(
description = ["APK file to be patched."],
arity = "1..1",
@ -176,14 +196,7 @@ internal object PatchCommand : Runnable {
this.apk = apk
}
@CommandLine.Option(
names = ["-m", "--merge"],
description = ["One or more DEX files or containers to merge into the APK."],
)
@Suppress("unused")
private fun setIntegrations(integrations: Set<File>) {
logger.warning("The --merge option is not used anymore.")
}
private lateinit var apk: File
@CommandLine.Option(
names = ["-b", "--patch-bundle"],
@ -198,6 +211,8 @@ internal object PatchCommand : Runnable {
this.patchBundles = patchBundles
}
private var patchBundles = emptySet<File>()
@CommandLine.Option(
names = ["--custom-aapt2-binary"],
description = ["Path to a custom AAPT binary to compile resources with."],
@ -226,11 +241,6 @@ internal object PatchCommand : Runnable {
"${outputFilePath.nameWithoutExtension}-temporary-files",
)
val optionsFile =
optionsFile ?: outputFilePath.parentFile.resolve(
"${outputFilePath.nameWithoutExtension}-options.json",
)
val keystoreFilePath =
keystoreFilePath ?: outputFilePath.parentFile
.resolve("${outputFilePath.nameWithoutExtension}.keystore")
@ -243,21 +253,10 @@ internal object PatchCommand : Runnable {
val patches = loadPatchesFromJar(patchBundles)
// Warn if a patch can not be found in the supplied patch bundles.
if (warn) {
patches.map { it.name }.toHashSet().let { availableNames ->
(includedPatches + excludedPatches).filter { name ->
!availableNames.contains(name)
}
}.let { unknownPatches ->
if (unknownPatches.isEmpty()) return@let
logger.warning("Unknown input of patches:\n${unknownPatches.joinToString("\n")}")
}
}
// endregion
val patcherTemporaryFilesPath = temporaryFilesPath.resolve("patcher")
val (packageName, patcherResult) = Patcher(
PatcherConfig(
apk,
@ -267,16 +266,20 @@ internal object PatchCommand : Runnable {
true,
),
).use { patcher ->
val filteredPatches =
patcher.filterPatchSelection(patches).also { patches ->
logger.info("Setting patch options")
val packageName = patcher.context.packageMetadata.packageName
val packageVersion = patcher.context.packageMetadata.packageVersion
if (optionsFile.exists()) {
patches.setOptions(optionsFile)
} else {
Options.serialize(patches, prettyPrint = true).let(optionsFile::writeText)
}
}
val filteredPatches = patches.filterPatchSelection(packageName, packageVersion)
logger.info("Setting patch options")
val patchesList = patches.toList()
selection.filter { it.include != null }.associate {
val includeSelection = it.include!!
(includeSelection.selector.name ?: patchesList[includeSelection.selector.index!!].name!!) to
includeSelection.options
}.let(filteredPatches::setOptions)
patcher += filteredPatches
@ -297,7 +300,7 @@ internal object PatchCommand : Runnable {
patcher.context.packageMetadata.packageName to patcher.get()
}
// region Save
// region Save.
apk.copyTo(temporaryFilesPath.resolve(apk.name), overwrite = true).apply {
patcherResult.applyTo(this)
@ -323,9 +326,9 @@ internal object PatchCommand : Runnable {
// endregion
// region Install
// region Install.
deviceSerial?.let { it ->
deviceSerial?.let {
val deviceSerial = it.ifEmpty { null }
runBlocking {
@ -352,64 +355,72 @@ internal object PatchCommand : Runnable {
}
/**
* Filter the patches to be added to the patcher. The filter is based on the following:
* Filter the patches based on the selection.
*
* @param patches The patches to filter.
* @param packageName The package name of the APK file to be patched.
* @param packageVersion The version of the APK file to be patched.
* @return The filtered patches.
*/
private fun Patcher.filterPatchSelection(patches: Set<Patch<*>>): Set<Patch<*>> =
buildSet {
val packageName = context.packageMetadata.packageName
val packageVersion = context.packageMetadata.packageVersion
private fun Set<Patch<*>>.filterPatchSelection(
packageName: String,
packageVersion: String,
): Set<Patch<*>> = buildSet {
val includedPatchesByName =
selection.asSequence().mapNotNull { it.include?.selector?.name }.toSet()
val includedPatchesByIndex =
selection.asSequence().mapNotNull { it.include?.selector?.index }.toSet()
patches.withIndex().forEach patch@{ (i, patch) ->
val patchName = patch.name!!
val excludedPatches =
selection.asSequence().mapNotNull { it.exclude?.selector?.name }.toSet()
val excludedPatchesByIndex =
selection.asSequence().mapNotNull { it.exclude?.selector?.index }.toSet()
val explicitlyExcluded = excludedPatches.contains(patchName) || excludedPatchesByIndex.contains(i)
if (explicitlyExcluded) return@patch logger.info("\"$patchName\" excluded manually")
this@filterPatchSelection.withIndex().forEach patchLoop@{ (i, patch) ->
val patchName = patch.name!!
// Make sure the patch is compatible with the supplied APK files package name and version.
patch.compatiblePackages?.let { packages ->
packages.singleOrNull { (name, _) -> name == packageName }?.let { (_, versions) ->
if (versions?.isEmpty() == true) {
return@patch logger.warning("\"$patchName\" incompatible with \"$packageName\"")
}
val isManuallyExcluded = patchName in excludedPatches || i in excludedPatchesByIndex
if (isManuallyExcluded) return@patchLoop logger.info("\"$patchName\" excluded manually")
val matchesVersion = force ||
versions?.let { it.any { version -> version == packageVersion } }
?: true
// Make sure the patch is compatible with the supplied APK files package name and version.
patch.compatiblePackages?.let { packages ->
packages.singleOrNull { (name, _) -> name == packageName }?.let { (_, versions) ->
if (versions?.isEmpty() == true) {
return@patchLoop logger.warning("\"$patchName\" incompatible with \"$packageName\"")
}
if (!matchesVersion) {
return@patch logger.warning(
"\"$patchName\" incompatible with $packageName $packageVersion " +
"but compatible with " +
packages.joinToString("; ") { (packageName, versions) ->
packageName + " " + versions!!.joinToString(", ")
},
)
}
} ?: return@patch logger.fine(
"\"$patchName\" incompatible with $packageName. " +
"It is only compatible with " +
packages.joinToString(", ") { (name, _) -> name },
)
val matchesVersion =
force || versions?.let { it.any { version -> version == packageVersion } } ?: true
return@let
} ?: logger.fine("\"$patchName\" has no package constraints")
if (!matchesVersion) {
return@patchLoop logger.warning(
"\"$patchName\" incompatible with $packageName $packageVersion " +
"but compatible with " +
packages.joinToString("; ") { (packageName, versions) ->
packageName + " " + versions!!.joinToString(", ")
},
)
}
} ?: return@patchLoop logger.fine(
"\"$patchName\" incompatible with $packageName. " +
"It is only compatible with " +
packages.joinToString(", ") { (name, _) -> name },
)
// If the patch is implicitly used, it will be only included if [exclusive] is false.
val implicitlyIncluded = !exclusive && patch.use
// If the patch is explicitly used, it will be included even if [exclusive] is false.
val explicitlyIncluded = includedPatches.contains(patchName) || includedPatchesByIndex.contains(i)
return@let
} ?: logger.fine("\"$patchName\" has no package constraints")
val included = implicitlyIncluded || explicitlyIncluded
if (!included) return@patch logger.info("\"$patchName\" excluded") // Case 1.
val isIncluded = !exclusive && patch.use
val isManuallyIncluded = patchName in includedPatchesByName || i in includedPatchesByIndex
add(patch)
logger.fine("\"$patchName\" added")
if (!(isIncluded || isManuallyIncluded)) {
return@patchLoop logger.info("\"$patchName\" excluded")
}
add(patch)
logger.fine("\"$patchName\" added")
}
}
private fun purge(resourceCachePath: File) {
val result =

View File

@ -13,7 +13,7 @@ import java.util.logging.Logger
description = ["Install an APK file to devices with the supplied ADB device serials"],
)
internal object InstallCommand : Runnable {
private val logger = Logger.getLogger(InstallCommand::class.java.name)
private val logger = Logger.getLogger(this::class.java.name)
@Parameters(
description = ["ADB device serials. If not supplied, the first connected device will be used."],

View File

@ -1,6 +1,9 @@
package app.revanced.cli.command.utility
import app.revanced.library.installation.installer.*
import app.revanced.library.installation.installer.AdbInstaller
import app.revanced.library.installation.installer.AdbInstallerResult
import app.revanced.library.installation.installer.AdbRootInstaller
import app.revanced.library.installation.installer.RootInstallerResult
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
@ -13,7 +16,7 @@ import java.util.logging.Logger
description = ["Uninstall a patched app from the devices with the supplied ADB device serials"],
)
internal object UninstallCommand : Runnable {
private val logger = Logger.getLogger(UninstallCommand::class.java.name)
private val logger = Logger.getLogger(this::class.java.name)
@Parameters(
description = ["ADB device serials. If not supplied, the first connected device will be used."],

View File

@ -0,0 +1,78 @@
package app.revanced.cli.command
import kotlin.test.Test
class OptionValueConverterTest {
@Test
fun `converts to string`() {
"string" convertsTo "string" because "Strings should remain the same"
}
@Test
fun `converts to null`() {
"null" convertsTo null because "null should convert to null"
"\"null\"" convertsTo "null" because "Escaped null should convert to a string"
}
@Test
fun `converts to boolean`() {
"true" convertsTo true because "true should convert to a boolean true"
"True" convertsTo true because "Casing should not matter"
"\"true\"" convertsTo "true" because "Escaped booleans should be converted to strings"
"\'True\'" convertsTo "True" because "Casing in escaped booleans should not matter"
"tr ue" convertsTo "tr ue" because "Malformed booleans should be converted to strings"
}
@Test
fun `converts to numbers`() {
"1" convertsTo 1 because "Integers should convert to integers"
"1.0" convertsTo 1.0 because "Doubles should convert to doubles"
"1.0f" convertsTo 1.0f because "The suffix f should convert to a float"
Long.MAX_VALUE.toString() convertsTo Long.MAX_VALUE because "Values that are too large for an integer should convert to longs"
"1L" convertsTo 1L because "The suffix L should convert to a long"
}
@Test
fun `converts escaped numbers to string`() {
"\"1\"" convertsTo "1" because "Escaped numbers should convert to strings"
"\"1.0\"" convertsTo "1.0" because "Escaped doubles should convert to strings"
"\"1L\"" convertsTo "1L" because "Escaped longs should convert to strings"
"\'1\'" convertsTo "1" because "Single quotes should not be treated as escape symbols"
"\'.0\'" convertsTo ".0" because "Single quotes should not be treated as escape symbols"
"\'1L\'" convertsTo "1L" because "Single quotes should not be treated as escape symbols"
}
@Test
fun `trims escape symbols once`() {
"\"\"\"1\"\"\"" convertsTo "\"\"1\"\"" because "The escape symbols should be trimmed once"
"\'\'\'1\'\'\'" convertsTo "''1''" because "Single quotes should not be treated as escape symbols"
}
@Test
fun `converts lists`() {
"1,2" convertsTo "1,2" because "Lists without square brackets should not be converted to lists"
"[1,2" convertsTo "[1,2" because "Invalid lists should not be converted to lists"
"\"[1,2]\"" convertsTo "[1,2]" because "Lists with escaped square brackets should not be converted to lists"
"[]" convertsTo emptyList<Any>() because "Empty untyped lists should convert to empty lists of any"
"int[]" convertsTo emptyList<Int>() because "Empty typed lists should convert to lists of the specified type"
"[[int[]]]" convertsTo listOf(listOf(emptyList<Int>())) because "Nested typed lists should convert to nested lists of the specified type"
"[\"int[]\"]" convertsTo listOf("int[]") because "Lists of escaped empty typed lists should not be converted to lists"
"[1,2,3]" convertsTo listOf(1, 2, 3) because "Lists of integers should convert to lists of integers"
"[[1]]" convertsTo listOf(listOf(1)) because "Nested lists with one element should convert to nested lists"
"[[1,2],[3,4]]" convertsTo listOf(listOf(1, 2), listOf(3, 4)) because "Nested lists should convert to nested lists"
"[\"1,2\"]" convertsTo listOf("1,2") because "Values in lists should not be split by commas in strings"
"[[\"1,2\"]]" convertsTo listOf(listOf("1,2")) because "Values in nested lists should not be split by commas in strings"
"[\"\\\"\"]" convertsTo listOf("\"") because "Escaped quotes in strings should be converted to quotes"
"[[\"\\\"\"]]" convertsTo listOf(listOf("\"")) because "Escaped quotes in strings nested in lists should be converted to quotes"
"[.1,.2f,,true,FALSE]" convertsTo listOf(.1, .2f, "", true, false) because "Values in lists should be converted to the correct type"
}
private val convert = OptionValueConverter()::convert
private infix fun String.convertsTo(to: Any?) = convert(this) to to
private infix fun Pair<Any?, Any?>.because(reason: String) = assert(this.first == this.second) { reason }
}