Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc5786951d | ||
|
|
de79b6bcd5 | ||
|
|
afcf7f0914 | ||
|
|
4714a11eef |
@@ -4,7 +4,7 @@ plugins {
|
||||
}
|
||||
|
||||
group = "org.kargs"
|
||||
version = "1.0.0"
|
||||
version = "1.0.4"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
@@ -12,8 +12,28 @@ repositories {
|
||||
|
||||
dependencies {
|
||||
testImplementation(kotlin("test"))
|
||||
implementation(kotlin("stdlib"))
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect:2.2.21")
|
||||
// implementation(kotlin("stdlib"))
|
||||
// implementation(kotlin("reflect"))
|
||||
|
||||
// JUnit 5 testing dependencies
|
||||
testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.0")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter-engine:5.10.0")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter-params:5.10.0")
|
||||
testImplementation(kotlin("test"))
|
||||
|
||||
// For assertions
|
||||
testImplementation("org.assertj:assertj-core:3.24.2")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
|
||||
testLogging {
|
||||
events("passed", "skipped", "failed")
|
||||
showStandardStreams = true
|
||||
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
mavenPublishing {
|
||||
@@ -48,3 +68,4 @@ mavenPublishing {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
rootProject.name = "karg"
|
||||
rootProject.name = "kargs"
|
||||
|
||||
@@ -1,23 +1,171 @@
|
||||
package org.kargs
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Sealed class representing different argument types with conversion and validation logic
|
||||
*/
|
||||
sealed class ArgType<T>(val typeName: String) {
|
||||
/**
|
||||
* Convert a string value to the target type
|
||||
* @throws ArgumentParseException if conversion fails
|
||||
*/
|
||||
abstract fun convert(value: String): T
|
||||
|
||||
/**
|
||||
* Validate that a value is acceptable for this type
|
||||
* @return true if valid, false otherwise
|
||||
*/
|
||||
open fun validate(value: T): Boolean = true
|
||||
|
||||
/**
|
||||
* Get a description of valid values for this type
|
||||
*/
|
||||
open fun getValidationDescription(): String = "any $typeName"
|
||||
|
||||
object StringType : ArgType<kotlin.String>("String") {
|
||||
override fun convert(value: String) = value
|
||||
}
|
||||
|
||||
object IntType : ArgType<kotlin.Int>("Int") {
|
||||
override fun convert(value: String) = value.toInt()
|
||||
override fun convert(value: String): kotlin.Int = value.toIntOrNull() ?: throw ArgumentParseException("`$value` is not a valid integer")
|
||||
}
|
||||
|
||||
object BooleanType : ArgType<kotlin.Boolean>("Boolean") {
|
||||
override fun convert(value: String) = value.toBoolean()
|
||||
override fun convert(value: String): kotlin.Boolean {
|
||||
return when (value.lowercase()) {
|
||||
"true", "yes", "1", "on" -> true
|
||||
"false", "no", "0", "off" -> false
|
||||
else -> throw ArgumentParseException("'$value' is not a valid boolean (true/false, yes/no, 1/0, on/off)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object DoubleType : ArgType<kotlin.Double>("Double") {
|
||||
override fun convert(value: String): kotlin.Double {
|
||||
return value.toDoubleOrNull()
|
||||
?: throw ArgumentParseException("'$value' is not a valid number")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a constrained integer type with min/max bounds
|
||||
*/
|
||||
class IntRange(val min: kotlin.Int, val max: kotlin.Int) : ArgType<kotlin.Int>("Int") {
|
||||
override fun convert(value: String): kotlin.Int {
|
||||
val intValue = value.toIntOrNull() ?: throw ArgumentParseException("`$value` is not a valid integer")
|
||||
if (intValue !in min..max) {
|
||||
throw ArgumentParseException("`$value` must be between $min and $max")
|
||||
}
|
||||
return intValue
|
||||
}
|
||||
|
||||
override fun getValidationDescription(): String = "integer between $min and $max"
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an enum type from list of valid choices
|
||||
*/
|
||||
class Choice(val choices: List<kotlin.String>) : ArgType<kotlin.String>("Choice") {
|
||||
override fun convert(value: String): kotlin.String {
|
||||
if (value !in choices) {
|
||||
throw ArgumentParseException("`$value` is not a valid choice. Valid options: ${choices.joinToString(", ")}")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
override fun getValidationDescription(): String = "one of: ${choices.joinToString(", ")}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional value type - can be used as flag or with value
|
||||
*/
|
||||
class OptionalValue(val defaultWhenPresent: String = "true") : ArgType<String>("OptionalValue") {
|
||||
override fun convert(value: String): String = value
|
||||
|
||||
override fun getValidationDescription(): String = "optional value or flag"
|
||||
}
|
||||
|
||||
/**
|
||||
* File path type with existence and permision validation
|
||||
*/
|
||||
class FilePath(
|
||||
val mustExist: Boolean = false,
|
||||
val mustBeFile: Boolean = false,
|
||||
val mustBeDirectory: Boolean = false,
|
||||
val mustBeReadable: Boolean = false,
|
||||
val mustBeWritable: Boolean = false
|
||||
) : ArgType<File>("File") {
|
||||
override fun convert(value: String): File = File(value)
|
||||
|
||||
override fun validate(value: File): Boolean {
|
||||
return when {
|
||||
mustExist && !value.exists() -> false
|
||||
mustBeFile && !value.isFile -> false
|
||||
mustBeDirectory && !value.isDirectory -> false
|
||||
mustBeReadable && !value.canRead() -> false
|
||||
mustBeWritable && !value.canWrite() -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
override fun getValidationDescription(): String {
|
||||
val conditions = mutableListOf<String>()
|
||||
if (mustExist) conditions.add("must exist")
|
||||
if (mustBeFile) conditions.add("must be a file")
|
||||
if (mustBeDirectory) conditions.add("must be a directory")
|
||||
if (mustBeReadable) conditions.add("must be readable")
|
||||
if (mustBeWritable) conditions.add("must be writable")
|
||||
|
||||
return if (conditions.isEmpty()) "file path" else "file path (${conditions.joinToString(", ")})"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
val String: ArgType<kotlin.String> = StringType
|
||||
val Int: ArgType<kotlin.Int> = IntType
|
||||
val Boolean: ArgType<kotlin.Boolean> = BooleanType
|
||||
val Double: ArgType<kotlin.Double> = DoubleType
|
||||
|
||||
/**
|
||||
* Create an integer type with bounds
|
||||
*/
|
||||
fun intRange(min: kotlin.Int, max: kotlin.Int) = IntRange(min, max)
|
||||
|
||||
/**
|
||||
* Create a choice type from valid options
|
||||
*/
|
||||
fun choice(vararg options: kotlin.String) = Choice(options.toList())
|
||||
|
||||
/**
|
||||
* Create an optional value or flag
|
||||
*/
|
||||
fun optionalValue(defaultWhenPresent: String = "true") = OptionalValue(defaultWhenPresent)
|
||||
|
||||
/**
|
||||
* File that must exist and be a regular file
|
||||
*/
|
||||
fun existingFile() = FilePath(mustExist = true, mustBeFile = true)
|
||||
|
||||
/**
|
||||
* Directory that must exist
|
||||
*/
|
||||
fun existingDirectory() = FilePath(mustExist = true, mustBeDirectory = true)
|
||||
|
||||
/**
|
||||
* File that must exist and be readable
|
||||
*/
|
||||
fun readableFile() = FilePath(mustExist = true, mustBeFile = true, mustBeReadable = true)
|
||||
|
||||
/**
|
||||
* File that must be writable (can be created if doesn't exist)
|
||||
*/
|
||||
fun writableFile() = FilePath(mustBeWritable = true)
|
||||
|
||||
/**
|
||||
* Any file path (no validation)
|
||||
*/
|
||||
fun filePath() = FilePath()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
package org.kargs
|
||||
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
/**
|
||||
* Represents a positional command-line argument
|
||||
*
|
||||
* @param type The type converter for this argument's value
|
||||
* @param name The name of this argument (used in help)
|
||||
* @param description Help text for this argument
|
||||
* @param required Whether this argument must be provided
|
||||
*/
|
||||
class Argument<T>(
|
||||
val type: ArgType<T>,
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
description: String? = null,
|
||||
val required: Boolean = true
|
||||
) : ReadWriteProperty<Any?, T?> {
|
||||
) : KargsProperty<T>(description) {
|
||||
init {
|
||||
require(name.isNotBlank()) { "Argument name cannot be blank" }
|
||||
}
|
||||
|
||||
private var _value: T? = null
|
||||
override fun parseValue(str: String) {
|
||||
value = type.convert(str)
|
||||
}
|
||||
|
||||
var value: T?
|
||||
get() = _value
|
||||
set(v) { _value = v }
|
||||
override fun isValid(): Boolean {
|
||||
return if (required) {
|
||||
value != null && type.validate(value!!)
|
||||
} else {
|
||||
value?.let { type.validate(it) } ?: true
|
||||
}
|
||||
}
|
||||
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>): T? = _value
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) { _value = value }
|
||||
|
||||
fun parseValue(input: String) {
|
||||
_value = type.convert(input)
|
||||
override fun getValidationError(): String? {
|
||||
return when {
|
||||
required && value == null -> "Argument '$name' is required"
|
||||
value != null && !type.validate(value!!) -> "Invalid value for argument '$name': expected ${type.getValidationDescription()}"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,53 @@
|
||||
package org.kargs
|
||||
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
/**
|
||||
* Represents a boolean flag that doesn't take a value (e.g. --verbose, -v)
|
||||
*
|
||||
* @param longName The long form name (used with --)
|
||||
* @param shortName The short form name (used with -)
|
||||
* @param description Help text for this flag
|
||||
* @param defaultValue Default value (typically false)
|
||||
*/
|
||||
class Flag(
|
||||
val longName: String,
|
||||
val shortName: String? = null,
|
||||
val description: String = "",
|
||||
val default: Boolean = false
|
||||
) : ReadWriteProperty<Any?, Boolean> {
|
||||
description: String? = null,
|
||||
private val defaultValue: Boolean = false,
|
||||
) : KargsProperty<Boolean>(description) {
|
||||
|
||||
private var _value = default
|
||||
private var wasExplicitlySet = false
|
||||
|
||||
var value: Boolean
|
||||
get() = _value
|
||||
set(v) { _value = v }
|
||||
init {
|
||||
value = defaultValue
|
||||
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>): Boolean = _value
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Boolean) { _value = value }
|
||||
// Validate names
|
||||
require(longName.isNotBlank()) { "Long name cannot be blank" }
|
||||
require(!longName.startsWith("-")) { "Long name should not start with dashes" }
|
||||
shortName?.let {
|
||||
require(it.length == 1) { "Short name must be exactly one character" }
|
||||
require(!it.startsWith("-")) { "Short name should not start with dashes" }
|
||||
}
|
||||
}
|
||||
|
||||
fun setFlag() { _value = true }
|
||||
override fun parseValue(str: String) {
|
||||
value = when (str.lowercase()) {
|
||||
"true", "yes", "1", "on" -> true
|
||||
"false", "no", "0", "off" -> false
|
||||
else -> throw ArgumentParseException("Invalid flag value: $str")
|
||||
}
|
||||
wasExplicitlySet = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the flag to true (called when flag is present)
|
||||
*/
|
||||
fun setFlag() {
|
||||
value = true
|
||||
wasExplicitlySet = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this flag was explicitly set
|
||||
*/
|
||||
fun isSet(): Boolean = wasExplicitlySet
|
||||
}
|
||||
|
||||
|
||||
51
src/main/kotlin/org/kargs/KargsProperty.kt
Normal file
51
src/main/kotlin/org/kargs/KargsProperty.kt
Normal file
@@ -0,0 +1,51 @@
|
||||
package org.kargs
|
||||
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
/**
|
||||
* Base class for all command-line argument properties (options, flags, arguments)
|
||||
*/
|
||||
abstract class KargsProperty<T>(val description: String? = null) {
|
||||
var value: T? = null
|
||||
protected set
|
||||
|
||||
internal lateinit var parent: Subcommand
|
||||
|
||||
/**
|
||||
* Parse a string value into the appropiate type
|
||||
* @throws ArgumentParseException if parsing fails
|
||||
*/
|
||||
abstract fun parseValue(str: String)
|
||||
|
||||
/**
|
||||
* Validate the current value
|
||||
* @return true if valid, false otherwise
|
||||
*/
|
||||
open fun isValid(): Boolean = true
|
||||
|
||||
/**
|
||||
* Get validation error message if value is invalid
|
||||
*/
|
||||
open fun getValidationError(): String? = null
|
||||
|
||||
/**
|
||||
* Property delagate setup - registers this property with its parent subcommand
|
||||
*/
|
||||
operator fun provideDelegate(thisRef: Subcommand, prop: KProperty<*>): KargsProperty<T> {
|
||||
parent = thisRef
|
||||
parent.registerProperty(this)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Property delagate getter
|
||||
*/
|
||||
operator fun getValue(thisRef: Subcommand, property: KProperty<*>): T? = value
|
||||
|
||||
/*
|
||||
* Property delagate setter
|
||||
*/
|
||||
operator fun setValue(thisRef: Subcommand, property: KProperty<*>, value: T?) {
|
||||
this.value = value
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,67 @@
|
||||
package org.kargs
|
||||
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
/**
|
||||
* Represents a command-line option that takes a value (e.g. --output file.txt)
|
||||
*
|
||||
* @param type The type converter for this options's value
|
||||
* @param longName The long form name (used with --)
|
||||
* @param shortName The short form name (used with -)
|
||||
* @param description Help text for this option
|
||||
* @param required Whether this option must be provided
|
||||
* @param defaultValue Default value if not provided
|
||||
*/
|
||||
class Option<T>(
|
||||
val type: ArgType<T>,
|
||||
val longName: String,
|
||||
val shortName: String? = null,
|
||||
val description: String = "",
|
||||
description: String? = null,
|
||||
val required: Boolean = false,
|
||||
val default: T? = null
|
||||
) : ReadWriteProperty<Any?, T?> {
|
||||
private val defaultValue: T? = null
|
||||
) : KargsProperty<T>(description) {
|
||||
|
||||
private var _value: T? = default
|
||||
private var wasExplicitlySet = false
|
||||
|
||||
var value: T?
|
||||
get() = _value
|
||||
set(v) { _value = v }
|
||||
init {
|
||||
// Set the default value if provided
|
||||
defaultValue?.let { value = it }
|
||||
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>): T? = _value
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) { _value = value }
|
||||
|
||||
fun parseValue(input: String) {
|
||||
_value = type.convert(input)
|
||||
// Validate names
|
||||
require(longName.isNotBlank()) { "Long name cannot be blank" }
|
||||
require(!longName.startsWith("-")) { "Long name should not start with dashes" }
|
||||
shortName?.let {
|
||||
require(it.length == 1) { "Short name must be exactly one character" }
|
||||
require(!it.startsWith("-")) { "Short name should not start with dashes" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun parseValue(str: String) {
|
||||
value = type.convert(str)
|
||||
wasExplicitlySet = true
|
||||
}
|
||||
|
||||
override fun isValid(): Boolean {
|
||||
return if (required) {
|
||||
value != null && type.validate(value!!)
|
||||
} else {
|
||||
value?.let { type.validate(it) } ?: true
|
||||
}
|
||||
}
|
||||
|
||||
override fun getValidationError(): String? {
|
||||
return when {
|
||||
required && value == null -> "Option --$longName is required"
|
||||
value != null && !type.validate(value!!) -> "Invalid value for --$longName: expected ${type.getValidationDescription()}"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the option has been set (different from default)
|
||||
*/
|
||||
fun isSet(): Boolean = wasExplicitlySet
|
||||
|
||||
/**
|
||||
* Get the value or default
|
||||
*/
|
||||
fun getValueOrDefault(): T? = value ?: defaultValue
|
||||
}
|
||||
|
||||
37
src/main/kotlin/org/kargs/OptionalOption.kt
Normal file
37
src/main/kotlin/org/kargs/OptionalOption.kt
Normal file
@@ -0,0 +1,37 @@
|
||||
package org.kargs
|
||||
|
||||
/**
|
||||
* Option that can be used as a flag or with a value
|
||||
*/
|
||||
class OptionalOption(
|
||||
val longName: String,
|
||||
val shortName: String? = null,
|
||||
description: String? = null,
|
||||
private val defaultWhenPresent: String = "true"
|
||||
) : KargsProperty<String>(description) {
|
||||
private var wasExplicitlySet = false
|
||||
|
||||
init {
|
||||
require(longName.isNotBlank()) { "Long name cannot be blank" }
|
||||
require(!longName.startsWith("-")) { "Long name should not start with dashes" }
|
||||
shortName?.let {
|
||||
require(it.length == 1) { "Short name must be exactly one character" }
|
||||
require(!it.startsWith("-")) { "Short name should not start with dashes" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun parseValue(str: String) {
|
||||
value = str
|
||||
wasExplicitlySet = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Set as flag (no value provided)
|
||||
*/
|
||||
fun setAsFlag() {
|
||||
value = defaultWhenPresent
|
||||
wasExplicitlySet = true
|
||||
}
|
||||
|
||||
fun isSet(): Boolean = wasExplicitlySet
|
||||
}
|
||||
@@ -1,98 +1,359 @@
|
||||
package org.kargs
|
||||
|
||||
class Parser(val programName: String) {
|
||||
/**
|
||||
* Main argument parser that handles command-line argument parsing and routing to subcommands.
|
||||
*
|
||||
* @param programName The name of the program (used in help messages)
|
||||
* @param config Configuration options for the parser behavior
|
||||
*/
|
||||
class Parser(
|
||||
val programName: String,
|
||||
private val config: ParserConfig = ParserConfig.DEFAULT
|
||||
) {
|
||||
private val commands = mutableListOf<Subcommand>()
|
||||
|
||||
/**
|
||||
* Register one or more subcommands with this parser
|
||||
*/
|
||||
fun subcommands(vararg cmds: Subcommand) {
|
||||
commands.addAll(cmds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the provided command-line arguments and execute the appropiate command
|
||||
*
|
||||
* @param args Array of command-line arguments
|
||||
*/
|
||||
fun parse(args: Array<String>) {
|
||||
if (handleVersionFlag(args)) {
|
||||
return // Exit after showing version
|
||||
}
|
||||
|
||||
if (args.isEmpty()) {
|
||||
printGlobalHelp()
|
||||
if (config.helpOnEmpty) {
|
||||
printGlobalHelp()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val cmdName = args[0]
|
||||
val cmd = commands.firstOrNull { it.name == cmdName || it.aliases.contains(cmdName) }
|
||||
val cmd = findCommand(cmdName)
|
||||
|
||||
if (cmd == null) {
|
||||
println("Unknown command: $cmdName")
|
||||
printGlobalHelp()
|
||||
printError("Unknown command: $cmdName")
|
||||
if (!config.strictMode) {
|
||||
printGlobalHelp()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check for help, global or command
|
||||
if (args.contains("--help") || args.contains("-h")) {
|
||||
cmd.printHelp()
|
||||
return
|
||||
}
|
||||
|
||||
// Reflection: find all Option, Flag, Argument properties
|
||||
val props = cmd::class.members.filterIsInstance<kotlin.reflect.KProperty<*>>()
|
||||
val options = props.mapNotNull { it.getter.call(cmd) as? Option<*> }
|
||||
val flags = props.mapNotNull { it.getter.call(cmd) as? Flag }
|
||||
val arguments = props.mapNotNull { it.getter.call(cmd) as? Argument<*> }
|
||||
try {
|
||||
parseCommandArgs(cmd, args.sliceArray(1 until args.size))
|
||||
validateRequiredOptions(cmd)
|
||||
cmd.execute()
|
||||
} catch (e: ArgumentParseException) {
|
||||
handleParseError(e, cmd)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a command by name or alias
|
||||
*/
|
||||
private fun findCommand(name: String): Subcommand? {
|
||||
val searchName = if (config.caseSensitive) name else name.lowercase()
|
||||
return commands.firstOrNull { cmd ->
|
||||
val cmdName = if (config.caseSensitive) cmd.name else cmd.name.lowercase()
|
||||
val aliases = if (config.caseSensitive) cmd.aliases else cmd.aliases.map { it.lowercase() }
|
||||
cmdName == searchName || aliases.contains(searchName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse arguments for a specific command
|
||||
*/
|
||||
private fun parseCommandArgs(cmd: Subcommand, args: Array<String>) {
|
||||
var i = 0
|
||||
val positionalArgs = mutableListOf<String>()
|
||||
|
||||
// Parse args starting at index 1
|
||||
var i = 1
|
||||
while (i < args.size) {
|
||||
val arg = args[i]
|
||||
|
||||
when {
|
||||
arg.startsWith("--") -> {
|
||||
val key = arg.removePrefix("--")
|
||||
val option = options.firstOrNull { it.longName == key }
|
||||
val flag = flags.firstOrNull { it.longName == key }
|
||||
|
||||
when {
|
||||
option != null -> {
|
||||
i++
|
||||
if (i >= args.size) throw IllegalArgumentException("Missing value for option --$key")
|
||||
option.parseValue(args[i])
|
||||
}
|
||||
flag != null -> flag.setFlag()
|
||||
else -> println("Unknown option --$key")
|
||||
}
|
||||
i = parseLongOption(cmd, key, args, i)
|
||||
}
|
||||
|
||||
arg.startsWith("-") -> {
|
||||
arg.startsWith("-") && arg.length > 1 -> {
|
||||
val key = arg.removePrefix("-")
|
||||
val option = options.firstOrNull { it.shortName == key }
|
||||
val flag = flags.firstOrNull { it.shortName == key }
|
||||
|
||||
when {
|
||||
option != null -> {
|
||||
i++
|
||||
if (i >= args.size) throw IllegalArgumentException("Missing value for option -$key")
|
||||
option.parseValue(args[i])
|
||||
}
|
||||
flag != null -> flag.setFlag()
|
||||
else -> println("Unknown option -$key")
|
||||
}
|
||||
i = parseShortOption(cmd, key, args, i)
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Positional arguments
|
||||
val nextArg = arguments.firstOrNull { it.value == null }
|
||||
if (nextArg != null) {
|
||||
nextArg.parseValue(arg)
|
||||
} else {
|
||||
println("Unexpected argument: $arg")
|
||||
}
|
||||
positionalArgs.add(arg)
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
// Execute the command
|
||||
cmd.execute()
|
||||
// Handle positional arguments
|
||||
parsePositionalArguments(cmd, positionalArgs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a long option (--option)
|
||||
*/
|
||||
private fun parseLongOption(cmd: Subcommand, key: String, args: Array<String>, index: Int): Int {
|
||||
val option = cmd.options.firstOrNull { it.longName == key }
|
||||
val flag = cmd.flags.firstOrNull { it.longName == key }
|
||||
val optionalOption = cmd.optionalOptions.firstOrNull { it.longName == key }
|
||||
|
||||
return when {
|
||||
option != null -> {
|
||||
if (index + 1 >= args.size) {
|
||||
throw ArgumentParseException("Missing value for option --$key")
|
||||
}
|
||||
try {
|
||||
option.parseValue(args[index + 1])
|
||||
index + 1
|
||||
} catch (e: Exception) {
|
||||
throw ArgumentParseException("Invalid value for option --$key: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
optionalOption != null -> {
|
||||
// Check if next arg exists and doesn't start with -
|
||||
if (index + 1 < args.size && !args[index + 1].startsWith("-")) {
|
||||
// Has value
|
||||
optionalOption.parseValue(args[index + 1])
|
||||
index + 1
|
||||
} else {
|
||||
// Used as flag
|
||||
optionalOption.setAsFlag()
|
||||
index
|
||||
}
|
||||
}
|
||||
flag != null -> {
|
||||
flag.setFlag()
|
||||
index
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (config.strictMode) {
|
||||
throw ArgumentParseException("Unknown option --$key")
|
||||
} else {
|
||||
printWarning("Unknown option --$key")
|
||||
index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a short option (-o)
|
||||
*/
|
||||
private fun parseShortOption(cmd: Subcommand, key: String, args: Array<String>, index: Int): Int {
|
||||
// Handle combined short flags like -acc
|
||||
if (key.length > 1) {
|
||||
key.forEach { char ->
|
||||
val flag = cmd.flags.firstOrNull { it.shortName == char.toString() }
|
||||
if (flag != null) {
|
||||
flag.setFlag()
|
||||
} else if (config.strictMode) {
|
||||
throw ArgumentParseException("Unknown flag -$char")
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
val option = cmd.options.firstOrNull { it.shortName == key }
|
||||
val flag = cmd.flags.firstOrNull { it.shortName == key }
|
||||
|
||||
return when {
|
||||
option != null -> {
|
||||
if (index + 1 >= args.size) {
|
||||
throw ArgumentParseException("Missing value for option -$key")
|
||||
}
|
||||
try {
|
||||
option.parseValue(args[index + 1])
|
||||
index + 1
|
||||
} catch (e: Exception) {
|
||||
throw ArgumentParseException("Invalid value for option -$key: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
flag != null -> {
|
||||
flag.setFlag()
|
||||
index
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (config.strictMode) {
|
||||
throw ArgumentParseException("Unknown option -$key")
|
||||
} else {
|
||||
printWarning("Unknown option -$key")
|
||||
index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse positional arguments
|
||||
*/
|
||||
private fun parsePositionalArguments(cmd: Subcommand, args: List<String>) {
|
||||
val arguments = cmd.arguments
|
||||
|
||||
if (args.size > arguments.size) {
|
||||
val extra = args.drop(arguments.size)
|
||||
if (config.strictMode) {
|
||||
throw ArgumentParseException("Too many arguments: ${extra.joinToString(", ")}")
|
||||
} else {
|
||||
printWarning("Ignoring extra arguments: ${extra.joinToString(", ")}")
|
||||
}
|
||||
}
|
||||
|
||||
args.forEachIndexed { index, value ->
|
||||
if (index < arguments.size) {
|
||||
try {
|
||||
arguments[index].parseValue(value)
|
||||
} catch (e: Exception) {
|
||||
throw ArgumentParseException("Invalid value for argument ${arguments[index].name}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that all required options have been provided
|
||||
*/
|
||||
private fun validateRequiredOptions(cmd: Subcommand) {
|
||||
val missingRequired = cmd.options.filter { it.required && it.value == null }
|
||||
val missingArgs = cmd.arguments.filter { it.required && it.value == null }
|
||||
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
if (missingRequired.isNotEmpty()) {
|
||||
val missing = missingRequired.joinToString(", ") { "--${it.longName}" }
|
||||
errors.add("Missing required options: $missing")
|
||||
}
|
||||
|
||||
if (missingArgs.isNotEmpty()) {
|
||||
val missing = missingArgs.joinToString(", ") { it.name }
|
||||
errors.add("Missing required arguments: $missing")
|
||||
}
|
||||
|
||||
if (errors.isNotEmpty()) {
|
||||
throw ArgumentParseException(errors.joinToString(", "))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print global help menu
|
||||
*/
|
||||
private fun printGlobalHelp() {
|
||||
println("Usage: $programName <command> [options]")
|
||||
println("\nCommands:")
|
||||
commands.forEach {
|
||||
println(" ${it.name}\t${it.description}")
|
||||
val versionInfo = config.programVersion?.let { " (v$it)" } ?: ""
|
||||
println(colorize("Usage: $programName$versionInfo <command> [options]", Color.BOLD))
|
||||
println()
|
||||
println(colorize("Commands:", Color.BOLD))
|
||||
commands.forEach { cmd ->
|
||||
val aliases = if (cmd.aliases.isNotEmpty()) " (${cmd.aliases.joinToString(", ")})" else ""
|
||||
println(" ${colorize(cmd.name, Color.GREEN)}$aliases")
|
||||
if (cmd.description.isNotEmpty()) {
|
||||
println(" ${cmd.description}")
|
||||
}
|
||||
}
|
||||
println()
|
||||
println("Use `$programName <command> --help` for more information about a command.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Print error message with optional coloring
|
||||
*/
|
||||
private fun printError(message: String) {
|
||||
println(colorize("Error: $message", Color.RED))
|
||||
}
|
||||
|
||||
/**
|
||||
* Print warning message with optional coloring
|
||||
*/
|
||||
private fun printWarning(message: String) {
|
||||
println(colorize("Warning: $message", Color.YELLOW))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply color to text if colors are enabled
|
||||
*/
|
||||
private fun colorize(text: String, color: Color): String {
|
||||
return if (config.colorsEnabled) {
|
||||
"${color.code}$text${Color.RESET.code}"
|
||||
} else {
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ANSI color codes for terminal output
|
||||
*/
|
||||
private enum class Color(val code: String) {
|
||||
RESET("\u001B[0m"),
|
||||
RED("\u001B[31m"),
|
||||
GREEN("\u001B[32m"),
|
||||
YELLOW("\u001B[33m"),
|
||||
BOLD("\u001B[1m")
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles parse errors
|
||||
*
|
||||
* @throw ArgumentParseException if in debug mode
|
||||
*/
|
||||
private fun handleParseError(e: ArgumentParseException, cmd: Subcommand) {
|
||||
printError(e.message ?: "Parse error")
|
||||
cmd.printHelp()
|
||||
|
||||
// Only show stack trace in debug mode
|
||||
if (System.getProperty("debug") == "true") {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
// Exit gracefully instead of throwing
|
||||
// kotlin.system.exitProcess(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if version flag is present and handle it
|
||||
*/
|
||||
private fun handleVersionFlag(args: Array<String>): Boolean {
|
||||
if (config.programVersion == null) return false
|
||||
|
||||
val versionFlags = listOf("--${config.versionLongName}", "-${config.versionShortName}")
|
||||
|
||||
if (args.any { it in versionFlags }) {
|
||||
printVersion()
|
||||
return true // Indicates version was shown, should exit
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Print version information
|
||||
*/
|
||||
private fun printVersion() {
|
||||
config.programVersion?.let { version ->
|
||||
println("$programName version $version")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom exception for argument parsing errors
|
||||
*/
|
||||
class ArgumentParseException(message: String) : Exception(message)
|
||||
|
||||
21
src/main/kotlin/org/kargs/ParserConfig.kt
Normal file
21
src/main/kotlin/org/kargs/ParserConfig.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package org.kargs
|
||||
|
||||
/**
|
||||
* Configuration class for customizing parser behavior
|
||||
*/
|
||||
data class ParserConfig(
|
||||
val colorsEnabled: Boolean = true,
|
||||
val strictMode: Boolean = false, // Whether to fail on unknown options
|
||||
val helpOnEmpty: Boolean = true, // Show help when no args provided
|
||||
val caseSensitive: Boolean = true,
|
||||
val allowAbbreviations: Boolean = false, // Allow partial option matching
|
||||
val programVersion: String? = null, // Adds --version and -v flag if set
|
||||
|
||||
// Optional customize version flag names
|
||||
val versionLongName: String = "version",
|
||||
val versionShortName: String = "V" // Capital V to avoid conflicts with -v (verbose)
|
||||
) {
|
||||
companion object {
|
||||
val DEFAULT = ParserConfig()
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,145 @@
|
||||
package org.kargs
|
||||
|
||||
|
||||
/**
|
||||
* Base class for defining subcommands with their options, flags, and arguments
|
||||
*
|
||||
* @param name The name for this subcommand
|
||||
* @param description Help description for this subcommand
|
||||
* @param aliases Alternative names for this subcommand
|
||||
*/
|
||||
abstract class Subcommand(
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
val aliases: List<String> = emptyList()
|
||||
) {
|
||||
private val _options = mutableListOf<Option<*>>()
|
||||
private val _flags = mutableListOf<Flag>()
|
||||
private val _arguments = mutableListOf<Argument<*>>()
|
||||
private val _optionalOptions = mutableListOf<OptionalOption>()
|
||||
|
||||
init {
|
||||
require(name.isNotBlank()) { "Subcommand name cannot be blank" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a property (option, flag, argument) with this subcommand
|
||||
* Called automatically by property delagates
|
||||
*/
|
||||
fun registerProperty(prop: KargsProperty<*>) {
|
||||
when (prop) {
|
||||
is Option<*> -> _options += prop
|
||||
is Flag -> _flags += prop
|
||||
is Argument<*> -> _arguments += prop
|
||||
is OptionalOption -> _optionalOptions += prop
|
||||
}
|
||||
}
|
||||
|
||||
// Public read-only access to registered properties
|
||||
val options: List<Option<*>> get() = _options
|
||||
val flags: List<Flag> get() = _flags
|
||||
val arguments: List<Argument<*>> get() = _arguments
|
||||
val optionalOptions: List<OptionalOption> get() = _optionalOptions
|
||||
|
||||
/**
|
||||
* Execute this subcommand - must be implemented by subclasses
|
||||
*/
|
||||
abstract fun execute()
|
||||
|
||||
open fun printHelp() {
|
||||
println("Usage: $name [options] [arguments]")
|
||||
if (description.isNotEmpty()) println(description)
|
||||
/**
|
||||
* Print help information for this subcommand
|
||||
*/
|
||||
fun printHelp() {
|
||||
println("Usage: $name [options]${if (arguments.isNotEmpty()) " ${arguments.joinToString(" ") { if (it.required) "<${it.name}>" else "[${it.name}]" }}" else ""}")
|
||||
|
||||
val props = this::class.members.filterIsInstance<kotlin.reflect.KProperty<*>>()
|
||||
val options = props.mapNotNull { it.getter.call(this) as? Option<*> }
|
||||
val flags = props.mapNotNull { it.getter.call(this) as? Flag }
|
||||
val arguments = props.mapNotNull { it.getter.call(this) as? Argument<*> }
|
||||
if (description.isNotEmpty()) {
|
||||
println()
|
||||
println(description)
|
||||
}
|
||||
|
||||
if (options.isNotEmpty()) {
|
||||
println("\nOptions:")
|
||||
options.forEach { o ->
|
||||
println(" ${o.shortName?.let { "-$it, " } ?: ""}--${o.longName}\t${o.description}")
|
||||
println()
|
||||
println("Options:")
|
||||
options.forEach { option ->
|
||||
val shortName = option.shortName?.let { "-$it, " } ?: " "
|
||||
val required = if (option.required) " (required)" else ""
|
||||
val defaultVal = option.getValueOrDefault()?.let { " [default: $it]" } ?: ""
|
||||
val typeInfo = getTypeInfo(option.type)
|
||||
|
||||
println(" $shortName--${option.longName}${typeInfo}")
|
||||
option.description?.let { desc ->
|
||||
println(" $desc$required$defaultVal")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (optionalOptions.isNotEmpty()) {
|
||||
println()
|
||||
println("Optional Value Options:")
|
||||
optionalOptions.forEach { option ->
|
||||
val shortName = option.shortName?.let { "-$it, " } ?: " "
|
||||
println(" $shortName--${option.longName} [value]")
|
||||
option.description?.let { desc ->
|
||||
println(" $desc (can be used as flag or with value)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.isNotEmpty()) {
|
||||
println("\nFlags:")
|
||||
flags.forEach { f ->
|
||||
println(" ${f.shortName?.let { "-$it, " } ?: ""}--${f.longName}\t${f.description}")
|
||||
println()
|
||||
println("Flags:")
|
||||
flags.forEach { flag ->
|
||||
val shortName = flag.shortName?.let { "-$it, " } ?: " "
|
||||
println(" $shortName--${flag.longName}")
|
||||
flag.description?.let { desc ->
|
||||
println(" $desc")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (arguments.isNotEmpty()) {
|
||||
println("\nArguments:")
|
||||
arguments.forEach { a ->
|
||||
println(" ${a.name}\t${a.description}")
|
||||
println()
|
||||
println("Arguments:")
|
||||
arguments.forEach { arg ->
|
||||
val required = if (arg.required) " (required)" else " (optional)"
|
||||
val typeInfo = getTypeInfo(arg.type)
|
||||
println(" ${arg.name}$typeInfo$required")
|
||||
arg.description?.let { desc ->
|
||||
println(" $desc")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all properties in this subcommand
|
||||
* @return list of validation errors, empty if all valid
|
||||
*/
|
||||
fun validate(): List<String> {
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
(options + flags + arguments).forEach { prop ->
|
||||
prop.getValidationError()?.let { error ->
|
||||
errors.add(error)
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Type info helper method
|
||||
*/
|
||||
private fun getTypeInfo(type: ArgType<*>): String {
|
||||
return when (type) {
|
||||
is ArgType.StringType -> " <string>"
|
||||
is ArgType.IntType -> " <int>"
|
||||
is ArgType.DoubleType -> " <double>"
|
||||
is ArgType.BooleanType -> " <bool>"
|
||||
is ArgType.IntRange -> " <${type.min}-${type.max}>"
|
||||
is ArgType.Choice -> " <${type.choices.joinToString("|")}>"
|
||||
is ArgType.OptionalValue -> " [string]"
|
||||
is ArgType.FilePath -> " <path>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
296
src/test/kotlin/org/kargs/tests/ArgTypeTest.kt
Normal file
296
src/test/kotlin/org/kargs/tests/ArgTypeTest.kt
Normal file
@@ -0,0 +1,296 @@
|
||||
package org.kargs.tests
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.ValueSource
|
||||
import org.kargs.ArgType
|
||||
import org.kargs.ArgumentParseException
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.io.path.createTempFile
|
||||
import kotlin.io.path.createTempDirectory
|
||||
|
||||
class ArgTypeTest {
|
||||
|
||||
@Test
|
||||
fun `String type converts correctly`() {
|
||||
val type = ArgType.String
|
||||
assertEquals("hello", type.convert("hello"))
|
||||
assertEquals("", type.convert(""))
|
||||
assertEquals("123", type.convert("123"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Int type converts valid integers`() {
|
||||
val type = ArgType.Int
|
||||
assertEquals(42, type.convert("42"))
|
||||
assertEquals(-10, type.convert("-10"))
|
||||
assertEquals(0, type.convert("0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Int type throws on invalid input`() {
|
||||
val type = ArgType.Int
|
||||
assertThrows<ArgumentParseException> { type.convert("not-a-number") }
|
||||
assertThrows<ArgumentParseException> { type.convert("12.5") }
|
||||
assertThrows<ArgumentParseException> { type.convert("") }
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = ["true", "TRUE", "yes", "YES", "1", "on", "ON"])
|
||||
fun `Boolean type converts true values`(input: String) {
|
||||
val type = ArgType.Boolean
|
||||
assertEquals(true, type.convert(input))
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = ["false", "FALSE", "no", "NO", "0", "off", "OFF"])
|
||||
fun `Boolean type converts false values`(input: String) {
|
||||
val type = ArgType.Boolean
|
||||
assertEquals(false, type.convert(input))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Boolean type throws on invalid input`() {
|
||||
val type = ArgType.Boolean
|
||||
assertThrows<ArgumentParseException> { type.convert("maybe") }
|
||||
assertThrows<ArgumentParseException> { type.convert("2") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Double type converts correctly`() {
|
||||
val type = ArgType.Double
|
||||
assertEquals(3.14, type.convert("3.14"))
|
||||
assertEquals(-2.5, type.convert("-2.5"))
|
||||
assertEquals(42.0, type.convert("42"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Double type throws on invalid input`() {
|
||||
val type = ArgType.Double
|
||||
assertThrows<ArgumentParseException> { type.convert("not-a-number") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IntRange validates bounds`() {
|
||||
val type = ArgType.intRange(1, 10)
|
||||
assertEquals(5, type.convert("5"))
|
||||
assertEquals(1, type.convert("1"))
|
||||
assertEquals(10, type.convert("10"))
|
||||
|
||||
assertThrows<ArgumentParseException> { type.convert("0") }
|
||||
assertThrows<ArgumentParseException> { type.convert("11") }
|
||||
assertThrows<ArgumentParseException> { type.convert("-5") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Choice validates options`() {
|
||||
val type = ArgType.choice("red", "green", "blue")
|
||||
assertEquals("red", type.convert("red"))
|
||||
assertEquals("green", type.convert("green"))
|
||||
assertEquals("blue", type.convert("blue"))
|
||||
|
||||
assertThrows<ArgumentParseException> { type.convert("yellow") }
|
||||
assertThrows<ArgumentParseException> { type.convert("RED") } // case sensitive
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IntRange provides correct validation description`() {
|
||||
val type = ArgType.intRange(5, 15)
|
||||
assertEquals("integer between 5 and 15", type.getValidationDescription())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Choice provides correct validation description`() {
|
||||
val type = ArgType.choice("apple", "banana", "cherry")
|
||||
assertEquals("one of: apple, banana, cherry", type.getValidationDescription())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FilePath converts string to File`() {
|
||||
val type = ArgType.filePath()
|
||||
val result = type.convert("/path/to/file.txt")
|
||||
assertEquals("/path/to/file.txt", result.path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FilePath with no constraints allows any path`() {
|
||||
val type = ArgType.filePath()
|
||||
// Should not throw for non-existent files
|
||||
val result = type.convert("/non/existent/path")
|
||||
assertEquals("/non/existent/path", result.path)
|
||||
assertEquals(true, type.validate(result))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `existingFile validates file exists and is file`() {
|
||||
val type = ArgType.existingFile()
|
||||
|
||||
// Create a temporary file for testing
|
||||
val tempFile = kotlin.io.path.createTempFile().toFile()
|
||||
tempFile.writeText("test content")
|
||||
|
||||
try {
|
||||
// Should work with existing file
|
||||
val result = type.convert(tempFile.absolutePath)
|
||||
assertEquals(true, type.validate(result))
|
||||
|
||||
// Should fail with non-existent file
|
||||
val nonExistent = type.convert("/non/existent/file.txt")
|
||||
assertEquals(false, type.validate(nonExistent))
|
||||
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `existingFile fails validation on directory`() {
|
||||
val type = ArgType.existingFile()
|
||||
|
||||
// Create a temporary directory
|
||||
val tempDir = kotlin.io.path.createTempDirectory().toFile()
|
||||
|
||||
try {
|
||||
val result = type.convert(tempDir.absolutePath)
|
||||
assertEquals(false, type.validate(result)) // Should fail because it's a directory, not a file
|
||||
|
||||
} finally {
|
||||
tempDir.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `existingDirectory validates directory exists and is directory`() {
|
||||
val type = ArgType.existingDirectory()
|
||||
|
||||
// Create a temporary directory
|
||||
val tempDir = kotlin.io.path.createTempDirectory().toFile()
|
||||
|
||||
try {
|
||||
// Should work with existing directory
|
||||
val result = type.convert(tempDir.absolutePath)
|
||||
assertEquals(true, type.validate(result))
|
||||
|
||||
// Should fail with non-existent directory
|
||||
val nonExistent = type.convert("/non/existent/directory")
|
||||
assertEquals(false, type.validate(nonExistent))
|
||||
|
||||
} finally {
|
||||
tempDir.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `existingDirectory fails validation on file`() {
|
||||
val type = ArgType.existingDirectory()
|
||||
|
||||
// Create a temporary file
|
||||
val tempFile = kotlin.io.path.createTempFile().toFile()
|
||||
tempFile.writeText("test")
|
||||
|
||||
try {
|
||||
val result = type.convert(tempFile.absolutePath)
|
||||
assertEquals(false, type.validate(result)) // Should fail because it's a file, not a directory
|
||||
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readableFile validates file is readable`() {
|
||||
val type = ArgType.readableFile()
|
||||
|
||||
// Create a temporary file
|
||||
val tempFile = kotlin.io.path.createTempFile().toFile()
|
||||
tempFile.writeText("test content")
|
||||
|
||||
try {
|
||||
// Should work with readable file
|
||||
val result = type.convert(tempFile.absolutePath)
|
||||
assertEquals(true, type.validate(result))
|
||||
|
||||
// Make file unreadable (this might not work on all systems)
|
||||
tempFile.setReadable(false)
|
||||
assertEquals(false, type.validate(result))
|
||||
|
||||
} finally {
|
||||
tempFile.setReadable(true) // Restore permissions
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `writableFile validates file is writable`() {
|
||||
val type = ArgType.writableFile()
|
||||
|
||||
// Create a temporary file
|
||||
val tempFile = kotlin.io.path.createTempFile().toFile()
|
||||
tempFile.writeText("test content")
|
||||
|
||||
try {
|
||||
// Should work with writable file
|
||||
val result = type.convert(tempFile.absolutePath)
|
||||
assertEquals(true, type.validate(result))
|
||||
|
||||
// Make file read-only (this might not work on all systems)
|
||||
tempFile.setWritable(false)
|
||||
assertEquals(false, type.validate(result))
|
||||
|
||||
} finally {
|
||||
tempFile.setWritable(true) // Restore permissions
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FilePath validation description includes constraints`() {
|
||||
assertEquals("file path", ArgType.filePath().getValidationDescription())
|
||||
assertEquals("file path (must exist, must be a file)", ArgType.existingFile().getValidationDescription())
|
||||
assertEquals("file path (must exist, must be a directory)", ArgType.existingDirectory().getValidationDescription())
|
||||
assertEquals("file path (must exist, must be a file, must be readable)", ArgType.readableFile().getValidationDescription())
|
||||
assertEquals("file path (must be writable)", ArgType.writableFile().getValidationDescription())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FilePath with multiple constraints`() {
|
||||
val type = ArgType.FilePath(
|
||||
mustExist = true,
|
||||
mustBeFile = true,
|
||||
mustBeReadable = true,
|
||||
mustBeWritable = true
|
||||
)
|
||||
|
||||
val tempFile = kotlin.io.path.createTempFile().toFile()
|
||||
tempFile.writeText("test")
|
||||
|
||||
try {
|
||||
val result = type.convert(tempFile.absolutePath)
|
||||
assertEquals(true, type.validate(result))
|
||||
|
||||
assertEquals(
|
||||
"file path (must exist, must be a file, must be readable, must be writable)",
|
||||
type.getValidationDescription()
|
||||
)
|
||||
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FilePath handles non-existent parent directories`() {
|
||||
val type = ArgType.writableFile()
|
||||
|
||||
// Path with non-existent parent directory
|
||||
val result = type.convert("/non/existent/parent/file.txt")
|
||||
|
||||
// Should convert fine (validation happens separately)
|
||||
assertEquals("/non/existent/parent/file.txt", result.path)
|
||||
|
||||
// Validation should fail because parent doesn't exist
|
||||
assertEquals(false, type.validate(result))
|
||||
}
|
||||
}
|
||||
|
||||
78
src/test/kotlin/org/kargs/tests/ArgumentTest.kt
Normal file
78
src/test/kotlin/org/kargs/tests/ArgumentTest.kt
Normal file
@@ -0,0 +1,78 @@
|
||||
package org.kargs.tests
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.kargs.Argument
|
||||
import org.kargs.ArgType
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class ArgumentTest {
|
||||
|
||||
@Test
|
||||
fun `argument creation`() {
|
||||
val arg = Argument(ArgType.String, "input", "Input file")
|
||||
assertEquals("input", arg.name)
|
||||
assertEquals("Input file", arg.description)
|
||||
assertTrue(arg.required)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `optional argument`() {
|
||||
val arg = Argument(ArgType.String, "output", "Output file", required = false)
|
||||
assertFalse(arg.required)
|
||||
assertTrue(arg.isValid()) // optional and null is valid
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `argument parsing`() {
|
||||
val arg = Argument(ArgType.Int, "count", "Item count")
|
||||
assertNull(arg.value)
|
||||
|
||||
arg.parseValue("42")
|
||||
assertEquals(42, arg.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `required argument validation`() {
|
||||
val arg = Argument(ArgType.String, "required", "Required arg", required = true)
|
||||
assertFalse(arg.isValid())
|
||||
assertEquals("Argument 'required' is required", arg.getValidationError())
|
||||
|
||||
arg.parseValue("value")
|
||||
assertTrue(arg.isValid())
|
||||
assertNull(arg.getValidationError())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `argument throws on blank name`() {
|
||||
assertThrows<IllegalArgumentException> {
|
||||
Argument(ArgType.String, "", "Description")
|
||||
}
|
||||
|
||||
assertThrows<IllegalArgumentException> {
|
||||
Argument(ArgType.String, " ", "Description") // whitespace only
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `argument with choice type`() {
|
||||
val arg = Argument(ArgType.choice("red", "green", "blue"), "color", "Color choice")
|
||||
|
||||
arg.parseValue("red")
|
||||
assertEquals("red", arg.value)
|
||||
assertTrue(arg.isValid())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `argument with range type`() {
|
||||
val arg = Argument(ArgType.intRange(1, 100), "percentage", "Percentage value")
|
||||
|
||||
arg.parseValue("50")
|
||||
assertEquals(50, arg.value)
|
||||
assertTrue(arg.isValid())
|
||||
}
|
||||
}
|
||||
|
||||
103
src/test/kotlin/org/kargs/tests/FlagTest.kt
Normal file
103
src/test/kotlin/org/kargs/tests/FlagTest.kt
Normal file
@@ -0,0 +1,103 @@
|
||||
package org.kargs.tests
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.kargs.ArgumentParseException
|
||||
import org.kargs.Flag
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.assertFalse
|
||||
|
||||
class FlagTest {
|
||||
|
||||
@Test
|
||||
fun `flag creation with default false`() {
|
||||
val flag = Flag("verbose", "v", "Verbose output")
|
||||
assertEquals("verbose", flag.longName)
|
||||
assertEquals("v", flag.shortName)
|
||||
assertEquals("Verbose output", flag.description)
|
||||
assertEquals(false, flag.value)
|
||||
assertFalse(flag.isSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flag creation with custom default`() {
|
||||
val flag = Flag("enabled", defaultValue = true)
|
||||
assertEquals(true, flag.value)
|
||||
assertFalse(flag.isSet()) // not explicitly set, just default
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setFlag updates value`() {
|
||||
val flag = Flag("debug", "d")
|
||||
assertEquals(false, flag.value)
|
||||
|
||||
flag.setFlag()
|
||||
assertEquals(true, flag.value)
|
||||
assertTrue(flag.isSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseValue handles boolean strings`() {
|
||||
val flag = Flag("test")
|
||||
|
||||
flag.parseValue("true")
|
||||
assertEquals(true, flag.value)
|
||||
|
||||
flag.parseValue("false")
|
||||
assertEquals(false, flag.value)
|
||||
|
||||
flag.parseValue("yes")
|
||||
assertEquals(true, flag.value)
|
||||
|
||||
flag.parseValue("no")
|
||||
assertEquals(false, flag.value)
|
||||
|
||||
flag.parseValue("1")
|
||||
assertEquals(true, flag.value)
|
||||
|
||||
flag.parseValue("0")
|
||||
assertEquals(false, flag.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseValue throws on invalid input`() {
|
||||
val flag = Flag("test")
|
||||
assertThrows<ArgumentParseException> {
|
||||
flag.parseValue("maybe")
|
||||
}
|
||||
|
||||
assertThrows<ArgumentParseException> {
|
||||
flag.parseValue("2")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flag throws on invalid names`() {
|
||||
assertThrows<IllegalArgumentException> {
|
||||
Flag("", "v") // blank long name
|
||||
}
|
||||
|
||||
assertThrows<IllegalArgumentException> {
|
||||
Flag("--invalid") // long name with dashes
|
||||
}
|
||||
|
||||
assertThrows<IllegalArgumentException> {
|
||||
Flag("valid", "ab") // short name too long
|
||||
}
|
||||
|
||||
assertThrows<IllegalArgumentException> {
|
||||
Flag("valid", "-v") // short name with dash
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flag isSet works correctly with defaults`() {
|
||||
val flag = Flag("test", defaultValue = true)
|
||||
assertFalse(flag.isSet()) // has default but not explicitly set
|
||||
|
||||
flag.setFlag()
|
||||
assertTrue(flag.isSet()) // now explicitly set
|
||||
}
|
||||
}
|
||||
|
||||
128
src/test/kotlin/org/kargs/tests/IntegrationTest.kt
Normal file
128
src/test/kotlin/org/kargs/tests/IntegrationTest.kt
Normal file
@@ -0,0 +1,128 @@
|
||||
package org.kargs.tests
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.kargs.*
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.assertFalse
|
||||
|
||||
class IntegrationTest {
|
||||
|
||||
class ComplexCommand : Subcommand("complex", "Complex test command", listOf("c")) {
|
||||
val input by Argument(ArgType.String, "input", "Input file")
|
||||
val output by Argument(ArgType.String, "output", "Output file", required = false)
|
||||
|
||||
val format by Option(ArgType.choice("json", "xml", "yaml"), "format", "f", "Output format", defaultValue = "json")
|
||||
val threads by Option(ArgType.intRange(1, 32), "threads", "t", "Thread count", defaultValue = 4)
|
||||
val timeout by Option(ArgType.Double, "timeout", description = "Timeout in seconds")
|
||||
val config by Option(ArgType.String, "config", "c", "Config file", required = true)
|
||||
|
||||
val verbose by Flag("verbose", "v", "Verbose output")
|
||||
val dryRun by Flag("dry-run", "n", "Dry run mode")
|
||||
val force by Flag("force", "f", "Force operation")
|
||||
|
||||
var executed = false
|
||||
|
||||
override fun execute() {
|
||||
executed = true
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `complex command with all features`() {
|
||||
val parser = Parser("myapp")
|
||||
val cmd = ComplexCommand()
|
||||
parser.subcommands(cmd)
|
||||
|
||||
parser.parse(arrayOf(
|
||||
"complex",
|
||||
"--config", "/path/to/config.yml",
|
||||
"--format", "xml",
|
||||
"--threads", "8",
|
||||
"--timeout", "30.5",
|
||||
"--verbose",
|
||||
"--dry-run",
|
||||
"input.txt",
|
||||
"output.xml"
|
||||
))
|
||||
|
||||
assertTrue(cmd.executed)
|
||||
assertEquals("input.txt", cmd.input)
|
||||
assertEquals("output.xml", cmd.output)
|
||||
assertEquals("/path/to/config.yml", cmd.config)
|
||||
assertEquals("xml", cmd.format)
|
||||
assertEquals(8, cmd.threads)
|
||||
assertEquals(30.5, cmd.timeout)
|
||||
assertEquals(true, cmd.verbose)
|
||||
assertEquals(true, cmd.dryRun)
|
||||
assertEquals(false, cmd.force)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `complex command with short options and combined flags`() {
|
||||
val parser = Parser("myapp")
|
||||
val cmd = ComplexCommand()
|
||||
parser.subcommands(cmd)
|
||||
|
||||
parser.parse(arrayOf(
|
||||
"c", // using alias
|
||||
"-c", "/config.yml",
|
||||
"-f", "yaml",
|
||||
"-t", "16",
|
||||
"-vnf", // combined flags: verbose, dry-run (n), force
|
||||
"input.txt"
|
||||
))
|
||||
|
||||
assertTrue(cmd.executed)
|
||||
assertEquals("input.txt", cmd.input)
|
||||
assertEquals("/config.yml", cmd.config)
|
||||
assertEquals("yaml", cmd.format)
|
||||
assertEquals(16, cmd.threads)
|
||||
assertEquals(true, cmd.verbose)
|
||||
assertEquals(true, cmd.dryRun)
|
||||
assertEquals(true, cmd.force)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple commands in same parser`() {
|
||||
val parser = Parser("tool")
|
||||
val buildCmd = TestUtils.TestSubcommand("build", "Build project")
|
||||
val testCmd = TestUtils.TestSubcommand("test", "Run tests")
|
||||
val deployCmd = TestUtils.TestSubcommand("deploy", "Deploy project")
|
||||
|
||||
parser.subcommands(buildCmd, testCmd, deployCmd)
|
||||
|
||||
// Test build command
|
||||
parser.parse(arrayOf("build", "--required", "value", "src/"))
|
||||
assertTrue(buildCmd.executed)
|
||||
|
||||
// Reset and test different command
|
||||
buildCmd.executed = false
|
||||
parser.parse(arrayOf("test", "--required", "value", "tests/"))
|
||||
assertTrue(testCmd.executed)
|
||||
assertFalse(buildCmd.executed) // should not be executed again
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validation works end-to-end`() {
|
||||
val parser = Parser("app")
|
||||
val cmd = ComplexCommand()
|
||||
parser.subcommands(cmd)
|
||||
|
||||
// Should validate all constraints
|
||||
parser.parse(arrayOf(
|
||||
"complex",
|
||||
"--config", "config.yml",
|
||||
"--format", "json", // valid choice
|
||||
"--threads", "8", // valid range
|
||||
"--timeout", "15.5", // valid double
|
||||
"input.txt"
|
||||
))
|
||||
|
||||
assertTrue(cmd.executed)
|
||||
assertEquals("json", cmd.format)
|
||||
assertEquals(8, cmd.threads)
|
||||
assertEquals(15.5, cmd.timeout)
|
||||
}
|
||||
}
|
||||
|
||||
95
src/test/kotlin/org/kargs/tests/OptionTest.kt
Normal file
95
src/test/kotlin/org/kargs/tests/OptionTest.kt
Normal file
@@ -0,0 +1,95 @@
|
||||
package org.kargs.tests
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.kargs.ArgType
|
||||
import org.kargs.Option
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class OptionTest {
|
||||
|
||||
@Test
|
||||
fun `option creation with valid parameters`() {
|
||||
val option = Option(ArgType.String, "output", "o", "Output file", required = true)
|
||||
assertEquals("output", option.longName)
|
||||
assertEquals("o", option.shortName)
|
||||
assertEquals("Output file", option.description)
|
||||
assertTrue(option.required)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `option with default value`() {
|
||||
val option = Option(ArgType.Int, "threads", "t", "Thread count", defaultValue = 4)
|
||||
assertEquals(4, option.value)
|
||||
assertEquals(4, option.getValueOrDefault())
|
||||
assertFalse(option.isSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `option parsing updates value`() {
|
||||
val option = Option(ArgType.String, "name", "n")
|
||||
assertNull(option.value)
|
||||
|
||||
option.parseValue("test")
|
||||
assertEquals("test", option.value)
|
||||
assertTrue(option.isSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `option validation for required field`() {
|
||||
val option = Option(ArgType.String, "required", required = true)
|
||||
assertFalse(option.isValid())
|
||||
assertEquals("Option --required is required", option.getValidationError())
|
||||
|
||||
option.parseValue("value")
|
||||
assertTrue(option.isValid())
|
||||
assertNull(option.getValidationError())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `option throws on invalid names`() {
|
||||
assertThrows<IllegalArgumentException> {
|
||||
Option(ArgType.String, "", "o") // blank long name
|
||||
}
|
||||
|
||||
assertThrows<IllegalArgumentException> {
|
||||
Option(ArgType.String, "--invalid", "o") // long name with dashes
|
||||
}
|
||||
|
||||
assertThrows<IllegalArgumentException> {
|
||||
Option(ArgType.String, "valid", "ab") // short name too long
|
||||
}
|
||||
|
||||
assertThrows<IllegalArgumentException> {
|
||||
Option(ArgType.String, "valid", "-o") // short name with dash
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `option with range type validation`() {
|
||||
val option = Option(ArgType.intRange(1, 10), "count", "c", "Item count")
|
||||
|
||||
option.parseValue("5")
|
||||
assertEquals(5, option.value)
|
||||
assertTrue(option.isValid())
|
||||
|
||||
// Test that the option itself doesn't validate the range (ArgType does)
|
||||
// The validation happens during parsing
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `option isSet works correctly with defaults`() {
|
||||
val option = Option(ArgType.String, "mode", defaultValue = "default")
|
||||
assertFalse(option.isSet()) // has default but not explicitly set
|
||||
|
||||
option.parseValue("custom")
|
||||
assertTrue(option.isSet()) // now explicitly set
|
||||
|
||||
option.parseValue("default") // set to same as default
|
||||
assertTrue(option.isSet()) // still considered "set"
|
||||
}
|
||||
}
|
||||
|
||||
107
src/test/kotlin/org/kargs/tests/ParserConfigTest.kt
Normal file
107
src/test/kotlin/org/kargs/tests/ParserConfigTest.kt
Normal file
@@ -0,0 +1,107 @@
|
||||
package org.kargs.tests
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.kargs.ArgumentParseException
|
||||
import org.kargs.Parser
|
||||
import org.kargs.ParserConfig
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.assertFalse
|
||||
|
||||
class ParserConfigTest {
|
||||
|
||||
@Test
|
||||
fun `default config values`() {
|
||||
val config = ParserConfig.DEFAULT
|
||||
assertTrue(config.colorsEnabled)
|
||||
assertFalse(config.strictMode)
|
||||
assertTrue(config.helpOnEmpty)
|
||||
assertTrue(config.caseSensitive)
|
||||
assertFalse(config.allowAbbreviations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom config values`() {
|
||||
val config = ParserConfig(
|
||||
colorsEnabled = false,
|
||||
strictMode = true,
|
||||
helpOnEmpty = false,
|
||||
caseSensitive = false,
|
||||
allowAbbreviations = true
|
||||
)
|
||||
|
||||
assertFalse(config.colorsEnabled)
|
||||
assertTrue(config.strictMode)
|
||||
assertFalse(config.helpOnEmpty)
|
||||
assertFalse(config.caseSensitive)
|
||||
assertTrue(config.allowAbbreviations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strict mode affects unknown options`() {
|
||||
val strictParser = Parser("test", ParserConfig(strictMode = true))
|
||||
val lenientParser = Parser("test", ParserConfig(strictMode = false))
|
||||
|
||||
val testCmd = TestUtils.TestSubcommand()
|
||||
strictParser.subcommands(testCmd)
|
||||
lenientParser.subcommands(testCmd)
|
||||
|
||||
// Strict mode should throw
|
||||
assertThrows<ArgumentParseException> {
|
||||
strictParser.parse(arrayOf("test", "--unknown", "value", "--required", "req", "input.txt"))
|
||||
}
|
||||
|
||||
// Lenient mode should warn but continue
|
||||
val output = TestUtils.captureOutput {
|
||||
lenientParser.parse(arrayOf("test", "--unknown", "value", "--required", "req", "input.txt"))
|
||||
}
|
||||
assertTrue(output.contains("Warning"))
|
||||
assertTrue(testCmd.executed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `helpOnEmpty config affects empty args behavior`() {
|
||||
val helpParser = Parser("test", ParserConfig(helpOnEmpty = true))
|
||||
val noHelpParser = Parser("test", ParserConfig(helpOnEmpty = false))
|
||||
|
||||
val testCmd = TestUtils.TestSubcommand()
|
||||
helpParser.subcommands(testCmd)
|
||||
noHelpParser.subcommands(testCmd)
|
||||
|
||||
// With helpOnEmpty = true, should show help
|
||||
val helpOutput = TestUtils.captureOutput {
|
||||
helpParser.parse(arrayOf())
|
||||
}
|
||||
assertTrue(helpOutput.contains("Usage:"))
|
||||
|
||||
// With helpOnEmpty = false, should do nothing
|
||||
val noHelpOutput = TestUtils.captureOutput {
|
||||
noHelpParser.parse(arrayOf())
|
||||
}
|
||||
assertEquals("", noHelpOutput)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `case sensitivity affects command matching`() {
|
||||
val caseSensitiveParser = Parser("test", ParserConfig(caseSensitive = true))
|
||||
val caseInsensitiveParser = Parser("test", ParserConfig(caseSensitive = false))
|
||||
|
||||
val testCmd = TestUtils.TestSubcommand("Test") // capital T
|
||||
caseSensitiveParser.subcommands(testCmd)
|
||||
caseInsensitiveParser.subcommands(testCmd)
|
||||
|
||||
// Case sensitive should not match "test" to "Test"
|
||||
val sensitiveOutput = TestUtils.captureOutput {
|
||||
caseSensitiveParser.parse(arrayOf("test", "--required", "value", "input.txt"))
|
||||
}
|
||||
assertTrue(sensitiveOutput.contains("Unknown command"))
|
||||
assertFalse(testCmd.executed)
|
||||
|
||||
// Case insensitive should match
|
||||
testCmd.executed = false // reset
|
||||
caseInsensitiveParser.parse(arrayOf("test", "--required", "value", "input.txt"))
|
||||
assertTrue(testCmd.executed)
|
||||
}
|
||||
}
|
||||
|
||||
175
src/test/kotlin/org/kargs/tests/ParserTest.kt
Normal file
175
src/test/kotlin/org/kargs/tests/ParserTest.kt
Normal file
@@ -0,0 +1,175 @@
|
||||
package org.kargs.tests
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.kargs.ArgumentParseException
|
||||
import org.kargs.Parser
|
||||
import org.kargs.ParserConfig
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.assertFalse
|
||||
|
||||
class ParserTest {
|
||||
|
||||
private lateinit var parser: Parser
|
||||
private lateinit var testCmd: TestUtils.TestSubcommand
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
parser = Parser("testapp")
|
||||
testCmd = TestUtils.TestSubcommand()
|
||||
parser.subcommands(testCmd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse basic command with required option`() {
|
||||
parser.parse(arrayOf("test", "--required", "value", "input.txt"))
|
||||
|
||||
assertTrue(testCmd.executed)
|
||||
assertEquals("value", testCmd.requiredOpt)
|
||||
assertEquals("input.txt", testCmd.inputArg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse command with short options`() {
|
||||
parser.parse(arrayOf("test", "-r", "required", "-s", "string", "-n", "42", "input.txt"))
|
||||
|
||||
assertTrue(testCmd.executed)
|
||||
assertEquals("required", testCmd.requiredOpt)
|
||||
assertEquals("string", testCmd.stringOpt)
|
||||
assertEquals(42, testCmd.intOpt)
|
||||
assertEquals("input.txt", testCmd.inputArg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse command with flags`() {
|
||||
parser.parse(arrayOf("test", "--required", "value", "--verbose", "--debug", "input.txt"))
|
||||
|
||||
assertTrue(testCmd.executed)
|
||||
assertEquals(true, testCmd.verboseFlag)
|
||||
assertEquals(true, testCmd.debugFlag)
|
||||
assertEquals(false, testCmd.forceFlag) // not set, should be default
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse command with combined short flags`() {
|
||||
parser.parse(arrayOf("test", "-r", "value", "-vf", "input.txt"))
|
||||
|
||||
assertTrue(testCmd.executed)
|
||||
assertEquals("value", testCmd.requiredOpt)
|
||||
assertEquals(true, testCmd.verboseFlag)
|
||||
assertEquals(true, testCmd.forceFlag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse command with all argument types`() {
|
||||
parser.parse(arrayOf(
|
||||
"test",
|
||||
"--required", "req",
|
||||
"--string", "hello",
|
||||
"--number", "123",
|
||||
"--range", "5",
|
||||
"--choice", "b",
|
||||
"--double", "3.14",
|
||||
"--verbose",
|
||||
"input.txt",
|
||||
"output.txt"
|
||||
))
|
||||
|
||||
assertTrue(testCmd.executed)
|
||||
assertEquals("req", testCmd.requiredOpt)
|
||||
assertEquals("hello", testCmd.stringOpt)
|
||||
assertEquals(123, testCmd.intOpt)
|
||||
assertEquals(5, testCmd.rangeOpt)
|
||||
assertEquals("b", testCmd.choiceOpt)
|
||||
assertEquals(3.14, testCmd.doubleOpt)
|
||||
assertEquals(true, testCmd.verboseFlag)
|
||||
assertEquals("input.txt", testCmd.inputArg)
|
||||
assertEquals("output.txt", testCmd.outputArg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `throws exception when required option missing`() {
|
||||
assertThrows<ArgumentParseException> {
|
||||
parser.parse(arrayOf("test", "input.txt"))
|
||||
}
|
||||
assertFalse(testCmd.executed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `throws exception on invalid integer`() {
|
||||
assertThrows<ArgumentParseException> {
|
||||
parser.parse(arrayOf("test", "--required", "value", "--number", "not-a-number", "input.txt"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `throws exception on out of range value`() {
|
||||
assertThrows<ArgumentParseException> {
|
||||
parser.parse(arrayOf("test", "--required", "value", "--range", "15", "input.txt"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `throws exception on invalid choice`() {
|
||||
assertThrows<ArgumentParseException> {
|
||||
parser.parse(arrayOf("test", "--required", "value", "--choice", "invalid", "input.txt"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles unknown command`() {
|
||||
val output = TestUtils.captureOutput {
|
||||
parser.parse(arrayOf("unknown", "arg"))
|
||||
}
|
||||
assertTrue(output.contains("Unknown command: unknown"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shows global help on empty args`() {
|
||||
val output = TestUtils.captureOutput {
|
||||
parser.parse(arrayOf())
|
||||
}
|
||||
assertTrue(output.contains("Usage: testapp <command>"))
|
||||
assertTrue(output.contains("test"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shows global help on --help`() {
|
||||
val output = TestUtils.captureOutput {
|
||||
parser.parse(arrayOf("--help"))
|
||||
}
|
||||
assertTrue(output.contains("Usage: testapp <command>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles command aliases`() {
|
||||
val aliasCmd = TestUtils.TestSubcommand("build", "Build command", listOf("b", "compile"))
|
||||
parser.subcommands(aliasCmd)
|
||||
|
||||
// Test main name
|
||||
parser.parse(arrayOf("build", "--required", "value", "input.txt"))
|
||||
assertTrue(aliasCmd.executed)
|
||||
|
||||
// Reset and test alias
|
||||
aliasCmd.executed = false
|
||||
parser.parse(arrayOf("b", "--required", "value", "input.txt"))
|
||||
assertTrue(aliasCmd.executed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles missing option value`() {
|
||||
assertThrows<ArgumentParseException> {
|
||||
parser.parse(arrayOf("test", "--required"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles missing short option value`() {
|
||||
assertThrows<ArgumentParseException> {
|
||||
parser.parse(arrayOf("test", "-r"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
159
src/test/kotlin/org/kargs/tests/SubcommandTest.kt
Normal file
159
src/test/kotlin/org/kargs/tests/SubcommandTest.kt
Normal file
@@ -0,0 +1,159 @@
|
||||
package org.kargs.tests
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.kargs.*
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class SubcommandTest {
|
||||
|
||||
@Test
|
||||
fun `subcommand creation with valid parameters`() {
|
||||
val cmd = TestUtils.TestSubcommand("build", "Build the project", listOf("b", "compile"))
|
||||
assertEquals("build", cmd.name)
|
||||
assertEquals("Build the project", cmd.description)
|
||||
assertEquals(listOf("b", "compile"), cmd.aliases)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subcommand throws on blank name`() {
|
||||
assertThrows<IllegalArgumentException> {
|
||||
TestUtils.TestSubcommand("", "Description")
|
||||
}
|
||||
|
||||
assertThrows<IllegalArgumentException> {
|
||||
TestUtils.TestSubcommand(" ", "Description") // whitespace only
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subcommand registers properties correctly`() {
|
||||
val cmd = TestUtils.TestSubcommand()
|
||||
|
||||
// Properties should be registered automatically via delegates
|
||||
assertTrue(cmd.options.isNotEmpty())
|
||||
assertTrue(cmd.flags.isNotEmpty())
|
||||
assertTrue(cmd.arguments.isNotEmpty())
|
||||
|
||||
// Check specific properties are registered
|
||||
assertTrue(cmd.options.any { it.longName == "required" })
|
||||
assertTrue(cmd.flags.any { it.longName == "verbose" })
|
||||
assertTrue(cmd.arguments.any { it.name == "input" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subcommand validation works`() {
|
||||
val cmd = TestUtils.TestSubcommand()
|
||||
|
||||
// Should have validation errors for required fields
|
||||
val errors = cmd.validate()
|
||||
assertTrue(errors.isNotEmpty())
|
||||
assertTrue(errors.any { it.contains("required") })
|
||||
|
||||
// Set required values through parsing (the proper way)
|
||||
val requiredOption = cmd.options.find { it.longName == "required" }
|
||||
val inputArgument = cmd.arguments.find { it.name == "input" }
|
||||
|
||||
requiredOption?.parseValue("value")
|
||||
inputArgument?.parseValue("input.txt")
|
||||
|
||||
// Should now be valid
|
||||
val errorsAfter = cmd.validate()
|
||||
assertTrue(errorsAfter.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subcommand help generation`() {
|
||||
val cmd = TestUtils.TestSubcommand("test", "Test command for validation")
|
||||
|
||||
val helpOutput = TestUtils.captureOutput {
|
||||
cmd.printHelp()
|
||||
}
|
||||
|
||||
assertTrue(helpOutput.contains("Usage: test [options]"))
|
||||
assertTrue(helpOutput.contains("Test command for validation"))
|
||||
assertTrue(helpOutput.contains("Options:"))
|
||||
assertTrue(helpOutput.contains("--required"))
|
||||
assertTrue(helpOutput.contains("Flags:"))
|
||||
assertTrue(helpOutput.contains("--verbose"))
|
||||
assertTrue(helpOutput.contains("Arguments:"))
|
||||
assertTrue(helpOutput.contains("input"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subcommand property access works`() {
|
||||
val cmd = TestUtils.TestSubcommand()
|
||||
|
||||
// Initially null/default values
|
||||
assertNull(cmd.requiredOpt)
|
||||
assertEquals(false, cmd.verboseFlag)
|
||||
assertNull(cmd.inputArg)
|
||||
|
||||
// Set values through parsing (simulating real usage)
|
||||
val requiredOption = cmd.options.find { it.longName == "required" }
|
||||
val verboseFlag = cmd.flags.find { it.longName == "verbose" }
|
||||
val inputArgument = cmd.arguments.find { it.name == "input" }
|
||||
|
||||
requiredOption?.parseValue("test")
|
||||
verboseFlag?.setFlag()
|
||||
inputArgument?.parseValue("file.txt")
|
||||
|
||||
// Values should be accessible
|
||||
assertEquals("test", cmd.requiredOpt)
|
||||
assertEquals(true, cmd.verboseFlag)
|
||||
assertEquals("file.txt", cmd.inputArg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subcommand property types are correct`() {
|
||||
val cmd = TestUtils.TestSubcommand()
|
||||
|
||||
// Check that we can find properties by their characteristics
|
||||
val stringOption = cmd.options.find { it.longName == "string" }
|
||||
val intOption = cmd.options.find { it.longName == "number" }
|
||||
val rangeOption = cmd.options.find { it.longName == "range" }
|
||||
val choiceOption = cmd.options.find { it.longName == "choice" }
|
||||
|
||||
assertTrue(stringOption != null)
|
||||
assertTrue(intOption != null)
|
||||
assertTrue(rangeOption != null)
|
||||
assertTrue(choiceOption != null)
|
||||
|
||||
// Test parsing different types
|
||||
stringOption?.parseValue("hello")
|
||||
intOption?.parseValue("42")
|
||||
rangeOption?.parseValue("5")
|
||||
choiceOption?.parseValue("b")
|
||||
|
||||
assertEquals("hello", cmd.stringOpt)
|
||||
assertEquals(42, cmd.intOpt)
|
||||
assertEquals(5, cmd.rangeOpt)
|
||||
assertEquals("b", cmd.choiceOpt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subcommand execution tracking works`() {
|
||||
val cmd = TestUtils.TestSubcommand()
|
||||
|
||||
assertFalse(cmd.executed)
|
||||
assertTrue(cmd.executionData.isEmpty())
|
||||
|
||||
// Set some values and execute
|
||||
val requiredOption = cmd.options.find { it.longName == "required" }
|
||||
val inputArgument = cmd.arguments.find { it.name == "input" }
|
||||
|
||||
requiredOption?.parseValue("test")
|
||||
inputArgument?.parseValue("input.txt")
|
||||
|
||||
cmd.execute()
|
||||
|
||||
assertTrue(cmd.executed)
|
||||
assertTrue(cmd.executionData.isNotEmpty())
|
||||
assertEquals("test", cmd.executionData["requiredOpt"])
|
||||
assertEquals("input.txt", cmd.executionData["inputArg"])
|
||||
}
|
||||
}
|
||||
|
||||
73
src/test/kotlin/org/kargs/tests/TestUtils.kt
Normal file
73
src/test/kotlin/org/kargs/tests/TestUtils.kt
Normal file
@@ -0,0 +1,73 @@
|
||||
package org.kargs.tests
|
||||
|
||||
import org.kargs.*
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.PrintStream
|
||||
|
||||
/**
|
||||
* Utility functions for testing
|
||||
*/
|
||||
object TestUtils {
|
||||
|
||||
/**
|
||||
* Capture console output during execution
|
||||
*/
|
||||
fun captureOutput(block: () -> Unit): String {
|
||||
val originalOut = System.out
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
System.setOut(PrintStream(outputStream))
|
||||
|
||||
try {
|
||||
block()
|
||||
return outputStream.toString().trim()
|
||||
} finally {
|
||||
System.setOut(originalOut)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test subcommand for testing purposes
|
||||
*/
|
||||
class TestSubcommand(
|
||||
name: String = "test",
|
||||
description: String = "Test command",
|
||||
aliases: List<String> = emptyList()
|
||||
) : Subcommand(name, description, aliases) {
|
||||
|
||||
var executed = false
|
||||
var executionData: Map<String, Any?> = emptyMap()
|
||||
|
||||
// Test properties
|
||||
val stringOpt by Option(ArgType.String, "string", "s", "String option")
|
||||
val requiredOpt by Option(ArgType.String, "required", "r", "Required option", required = true)
|
||||
val intOpt by Option(ArgType.Int, "number", "n", "Integer option")
|
||||
val rangeOpt by Option(ArgType.intRange(1, 10), "range", description = "Range option")
|
||||
val choiceOpt by Option(ArgType.choice("a", "b", "c"), "choice", "c", "Choice option")
|
||||
val doubleOpt by Option(ArgType.Double, "double", "d", "Double option")
|
||||
|
||||
val verboseFlag by Flag("verbose", "v", "Verbose flag")
|
||||
val debugFlag by Flag("debug", description = "Debug flag")
|
||||
val forceFlag by Flag("force", "f", "Force flag", defaultValue = false)
|
||||
|
||||
val inputArg by Argument(ArgType.String, "input", "Input argument")
|
||||
val outputArg by Argument(ArgType.String, "output", "Output argument", required = false)
|
||||
|
||||
override fun execute() {
|
||||
executed = true
|
||||
executionData = mapOf(
|
||||
"stringOpt" to stringOpt,
|
||||
"requiredOpt" to requiredOpt,
|
||||
"intOpt" to intOpt,
|
||||
"rangeOpt" to rangeOpt,
|
||||
"choiceOpt" to choiceOpt,
|
||||
"doubleOpt" to doubleOpt,
|
||||
"verboseFlag" to verboseFlag,
|
||||
"debugFlag" to debugFlag,
|
||||
"forceFlag" to forceFlag,
|
||||
"inputArg" to inputArg,
|
||||
"outputArg" to outputArg
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user