Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor cli argument parsing, support single quote assignments #57

Merged
merged 1 commit into from
Apr 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.apurebase.arkenv.feature.cli

/**
* Parses the command line arguments.
*/
internal class CliArgumentParser {
private val allowedSurroundings = listOf("'", "\"")
private val list = mutableListOf<String>()
private var isReading = false

/**
* Parses the provided [arguments] and returns the accumulated results.
* @param arguments List of raw command line string arguments to parse.
*/
fun parseArguments(arguments: List<String>): List<String> {
arguments.forEach(::parse)
return list
}

private fun parse(value: String) {
when {
isReading -> list[list.lastIndex] = "${list.last()} $value"
else -> list.add(value)
}

when {
isReading && value.endsWith(allowedSurroundings) -> {
list[list.lastIndex] = list.last().removeSurrounding(allowedSurroundings)
isReading = false
}
!isReading && value.startsWith(allowedSurroundings) -> isReading = true
}
}

private fun String.removeSurrounding(list: Iterable<CharSequence>): String =
list.fold(this, String::removeSurrounding)

private fun String.startsWith(list: Iterable<String>): Boolean = list.any(::startsWith)

private fun String.endsWith(list: Iterable<CharSequence>): Boolean = list.any(::endsWith)
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
package com.apurebase.arkenv.feature.cli

import com.apurebase.arkenv.*
import com.apurebase.arkenv.Arkenv
import com.apurebase.arkenv.argument.Argument
import com.apurebase.arkenv.argument.ArkenvArgument
import com.apurebase.arkenv.feature.ArkenvFeature
import com.apurebase.arkenv.util.*
import com.apurebase.arkenv.util.endsWith
import com.apurebase.arkenv.util.mapRelaxed
import com.apurebase.arkenv.util.startsWith
import com.apurebase.arkenv.util.putAll
import com.apurebase.arkenv.util.toSnakeCase
import kotlin.collections.set

Expand All @@ -22,10 +20,10 @@ class CliFeature : ArkenvFeature {
override fun onLoad(arkenv: Arkenv) {
args = arkenv.argList
args.replaceAll(String::mapRelaxed)
loadCliAssignments().let(arkenv::putAll)
val parsed = parseArguments(args)
val parsed = CliArgumentParser().parseArguments(args)
args.clear()
args.addAll(parsed)
loadCliAssignments().let(arkenv::putAll)
}

override fun onParse(arkenv: Arkenv, delegate: ArkenvArgument<*>): String? =
Expand Down Expand Up @@ -66,25 +64,6 @@ class CliFeature : ArkenvFeature {

private fun parseCli(index: Int): String? = args.getOrNull(index + 1)

private fun parseArguments(arguments: List<String>): List<String> {
val list = mutableListOf<String>()
var isReading = false
arguments.forEach { value ->
when {
isReading -> list[list.lastIndex] = "${list.last()} $value"
else -> list.add(value)
}
when {
isReading && value.endsWith(allowedSurroundings) -> {
list[list.lastIndex] = list.last().removeSurrounding(allowedSurroundings)
isReading = false
}
!isReading && value.startsWith(allowedSurroundings) -> isReading = true
}
}
return list
}

private fun findIndex(argument: Argument<*>, arguments: List<String>): Int? = when {
argument.isMainArg -> arguments.size - 2
else -> argument
Expand All @@ -105,6 +84,4 @@ class CliFeature : ArkenvFeature {
private fun removeValueArgument(index: Int, isBoolean: Boolean, value: Any?, default: Boolean) {
if (!isBoolean && !default && value != null) args.removeAt(index + 1)
}

private val allowedSurroundings = listOf("'", "\"")
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,13 @@ internal fun String.toSnakeCase() = this
.toUpperCase()
.removePrefixes("_")

internal fun String.removePrefixes(prefix: CharSequence): String = this
private fun String.removePrefixes(prefix: CharSequence): String = this
.removePrefix(prefix)
.let {
if (it.startsWith(prefix)) it.removePrefixes(prefix)
else it
}

internal fun String.endsWith(list: Iterable<String>): Boolean = list.any(::endsWith)

internal fun String.startsWith(list: Iterable<String>): Boolean = list.any(::startsWith)

internal fun String.removeSurrounding(list: Iterable<String>): String =
list.fold(this) { acc, s -> acc.removeSurrounding(s) }

internal fun String.isAdvancedName() = startsWith("--")

internal fun String.isSimpleName() = startsWith("-") && !isAdvancedName()
Expand Down
4 changes: 2 additions & 2 deletions arkenv/src/test/kotlin/com/apurebase/arkenv/LookupTests.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,14 @@ class LookupTests {
val ark = Ark().parse("--other", "name")
val key = "left-over"
assertThrows<MissingArgumentException> { ark[key] }
.expectThat { get { message }.isNotNull().contains(key) }
.expectThat { get { message }.isNotNull() contains key }
}

@Test fun `input key should be snake-case formatted`() {
val expected = "app"
MockSystem("CLIENT_DIR" to expected)
Ark().parse().expectThat {
get { get("clientDir") }.isEqualTo(expected)
get { get("clientDir") } isEqualTo expected
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package com.apurebase.arkenv.feature.cli

import com.apurebase.arkenv.Arkenv
import com.apurebase.arkenv.util.argument
import com.apurebase.arkenv.test.expectThat
import com.apurebase.arkenv.test.parse
import com.apurebase.arkenv.util.argument
import org.junit.jupiter.api.Test
import strikt.api.expectThat
import strikt.assertions.isEqualTo

/**
* Verify that command-line assignment style arguments (key=value) are parsed correctly.
* @see CliFeature
*/
class AssignmentTest {

private class Ark : Arkenv() {
Expand All @@ -18,29 +23,36 @@ class AssignmentTest {
}

@Test fun `should parse assignment correctly`() {
Ark().parse("int=4").expectThat {
get { int }.isEqualTo(4)
get { bool }.isEqualTo(true)
Ark().parse("int=4") expectThat {
get { int } isEqualTo 4
get { bool } isEqualTo true
}
}

@Test fun `should turn bool off`() {
Ark().parse("int=-1", "bool=false").expectThat {
get { int }.isEqualTo(-1)
get { bool }.isEqualTo(false)
Ark().parse("int=-1", "bool=false") expectThat {
get { int } isEqualTo -1
get { bool } isEqualTo false
}
}

@Test fun `should be able to use complex arg in assignment`() {
Ark().parse("int=0", "complex-arg=false").expectThat {
get { bool }.isEqualTo(false)
Ark().parse("int=0", "complex-arg=false") expectThat {
get { bool } isEqualTo false
}
}

@Test fun `should still allow = as part of other args`() {
Ark().parse("--str", "key=value", "int=1").expectThat {
get { string }.isEqualTo("key=value")
get { int }.isEqualTo(1)
Ark().parse("--str", "key=value", "int=1") expectThat {
get { string } isEqualTo "key=value"
get { int } isEqualTo 1
}
}

@Test fun `single quoted assignment`() {
val ark = Ark().parse("int=0", "'str=test", "expected'")
expectThat(ark) {
get { string } isEqualTo "test expected"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.apurebase.arkenv.feature.cli

import org.junit.jupiter.api.DynamicTest
import org.junit.jupiter.api.TestFactory
import strikt.api.expectThat
import strikt.assertions.get
import strikt.assertions.isEqualTo

/**
* Verify the behavior of the [CliArgumentParser].
*/
internal class CliArgumentParserTest {

private data class TestCase(val name: String, val input: List<String>, val expected: String)

private val testCases = listOf(
TestCase("no quotes", listOf("hello", "world"), "hello"),
TestCase("double quotes", listOf("\"hello", "world\""), "hello world"),
TestCase("single quote inside value", listOf("D'vloper"), "D'vloper"),
TestCase("single quoted", listOf("'test", "expected'"), "test expected"),
TestCase("single quoted, containing singlq quote", listOf("'test", "ex'pected'"), "test ex'pected"),
jeggy marked this conversation as resolved.
Show resolved Hide resolved
)

@TestFactory
fun parseArguments(): List<DynamicTest> = testCases.map { (name, input, expected) ->
DynamicTest.dynamicTest(name) {
// Arrange
val parser = CliArgumentParser()

// Act
val actual = parser.parseArguments(input)

// Assert
expectThat(actual)[0] isEqualTo expected
}
}
}
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
package com.apurebase.arkenv.feature.cli

import com.apurebase.arkenv.util.get
import com.apurebase.arkenv.test.Nullable
import com.apurebase.arkenv.test.expectThat
import com.apurebase.arkenv.test.parse
import com.apurebase.arkenv.util.get
import org.junit.jupiter.api.Test
import strikt.api.expectThat
import strikt.assertions.isEqualTo

/**
* Verify the behavior of the [CliFeature].
*/
internal class CliFeatureTest {

@Test fun `resolve undeclared get calls`() {
val key = "--undeclared"
val expected = "expected"
Nullable().parse(key, expected).expectThat {
get { get(key) }.isEqualTo(expected)
}
val configuration = Nullable().parse(key, expected)
expectThat(configuration).get { get(key) } isEqualTo expected
}

@Test fun `resolve undeclared assignments`() {
val key = "undeclared"
val expected = "expected"
Nullable().parse("$key=$expected").expectThat {
get { get(key) }.isEqualTo(expected)
}
val configuration = Nullable().parse("$key=$expected")
expectThat(configuration).get { get(key) } isEqualTo expected
}

@Test fun `single quote in value should parse entire string`() {
val expected = "D'vloper"
val configuration = Nullable().parse("-s", expected)
expectThat(configuration).get { str } isEqualTo expected
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import strikt.api.Assertion
import strikt.assertions.isEqualTo
import java.io.File

fun <T> T.expectThat(block: Assertion.Builder<T>.() -> Unit) = strikt.api.expectThat(this, block)
infix fun <T> T.expectThat(block: Assertion.Builder<T>.() -> Unit) = strikt.api.expectThat(this, block)

infix fun <T: Any?> T.expectIsEqual(expected: T) = expectThat { isEqualTo(expected) }

Expand Down