Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfa7d10048 | ||
|
|
8d05aa4ff5 | ||
|
|
c6470be0df | ||
|
|
fc5786951d | ||
|
|
de79b6bcd5 | ||
|
|
afcf7f0914 |
45
README.md
Normal file
45
README.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# kargs
|
||||||
|
All-in-one tool for building cli applications in Kotlin
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
```kotlin
|
||||||
|
/** build.gradle.kts */
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("org.kargs:kargs:version")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
|
||||||
|
## Parser
|
||||||
|
```kotlin
|
||||||
|
// Main.kt
|
||||||
|
import org.kargs.*
|
||||||
|
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
val parser = Parser("program name")
|
||||||
|
|
||||||
|
// Register subcommands
|
||||||
|
parser.subcommands(
|
||||||
|
TestCommand1(),
|
||||||
|
TestCommand2(),
|
||||||
|
...
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.parse(args)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Subcommand
|
||||||
|
```kotlin
|
||||||
|
// Subcommand.kt
|
||||||
|
import org.kargs.*
|
||||||
|
|
||||||
|
class TestCommand : Subcommand("name", "description", aliases = listOf("alias1", "alias2")) {
|
||||||
|
override fun execute() {
|
||||||
|
println("Logic")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group = "org.kargs"
|
group = "org.kargs"
|
||||||
version = "1.0.2"
|
version = "1.0.8"
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
@@ -12,8 +12,8 @@ repositories {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
testImplementation(kotlin("test"))
|
testImplementation(kotlin("test"))
|
||||||
implementation(kotlin("stdlib"))
|
// implementation(kotlin("stdlib"))
|
||||||
implementation(kotlin("reflect"))
|
// implementation(kotlin("reflect"))
|
||||||
|
|
||||||
// JUnit 5 testing dependencies
|
// JUnit 5 testing dependencies
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.0")
|
testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.0")
|
||||||
@@ -68,3 +68,4 @@ mavenPublishing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package org.kargs
|
package org.kargs
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sealed class representing different argument types with conversion and validation logic
|
* Sealed class representing different argument types with conversion and validation logic
|
||||||
*/
|
*/
|
||||||
@@ -49,7 +51,7 @@ sealed class ArgType<T>(val typeName: String) {
|
|||||||
/**
|
/**
|
||||||
* Create a constrained integer type with min/max bounds
|
* Create a constrained integer type with min/max bounds
|
||||||
*/
|
*/
|
||||||
class IntRange(private val min: kotlin.Int, private val max: kotlin.Int) : ArgType<kotlin.Int>("Int") {
|
class IntRange(val min: kotlin.Int, val max: kotlin.Int) : ArgType<kotlin.Int>("Int") {
|
||||||
override fun convert(value: String): kotlin.Int {
|
override fun convert(value: String): kotlin.Int {
|
||||||
val intValue = value.toIntOrNull() ?: throw ArgumentParseException("`$value` is not a valid integer")
|
val intValue = value.toIntOrNull() ?: throw ArgumentParseException("`$value` is not a valid integer")
|
||||||
if (intValue !in min..max) {
|
if (intValue !in min..max) {
|
||||||
@@ -64,7 +66,7 @@ sealed class ArgType<T>(val typeName: String) {
|
|||||||
/**
|
/**
|
||||||
* Create an enum type from list of valid choices
|
* Create an enum type from list of valid choices
|
||||||
*/
|
*/
|
||||||
class Choice(private val choices: List<kotlin.String>) : ArgType<kotlin.String>("Choice") {
|
class Choice(val choices: List<kotlin.String>) : ArgType<kotlin.String>("Choice") {
|
||||||
override fun convert(value: String): kotlin.String {
|
override fun convert(value: String): kotlin.String {
|
||||||
if (value !in choices) {
|
if (value !in choices) {
|
||||||
throw ArgumentParseException("`$value` is not a valid choice. Valid options: ${choices.joinToString(", ")}")
|
throw ArgumentParseException("`$value` is not a valid choice. Valid options: ${choices.joinToString(", ")}")
|
||||||
@@ -75,6 +77,51 @@ sealed class ArgType<T>(val typeName: String) {
|
|||||||
override fun getValidationDescription(): String = "one of: ${choices.joinToString(", ")}"
|
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<kotlin.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<java.io.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 {
|
companion object {
|
||||||
val String: ArgType<kotlin.String> = StringType
|
val String: ArgType<kotlin.String> = StringType
|
||||||
val Int: ArgType<kotlin.Int> = IntType
|
val Int: ArgType<kotlin.Int> = IntType
|
||||||
@@ -90,5 +137,35 @@ sealed class ArgType<T>(val typeName: String) {
|
|||||||
* Create a choice type from valid options
|
* Create a choice type from valid options
|
||||||
*/
|
*/
|
||||||
fun choice(vararg options: kotlin.String) = Choice(options.toList())
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
342
src/main/kotlin/org/kargs/Colors.kt
Normal file
342
src/main/kotlin/org/kargs/Colors.kt
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
package org.kargs
|
||||||
|
|
||||||
|
object Colors {
|
||||||
|
enum class Color(val code: String) {
|
||||||
|
RESET("\u001B[0m"),
|
||||||
|
|
||||||
|
// Base colors
|
||||||
|
BLACK("\u001B[30m"),
|
||||||
|
RED("\u001B[31m"),
|
||||||
|
GREEN("\u001B[32m"),
|
||||||
|
YELLOW("\u001B[33m"),
|
||||||
|
BLUE("\u001B[34m"),
|
||||||
|
MAGENTA("\u001B[35m"),
|
||||||
|
CYAN("\u001B[36m"),
|
||||||
|
WHITE("\u001B[37m"),
|
||||||
|
GRAY("\u001B[90m"),
|
||||||
|
|
||||||
|
// Bright colors
|
||||||
|
BRIGHT_BLACK("\u001B[90m"),
|
||||||
|
BRIGHT_RED("\u001B[91m"),
|
||||||
|
BRIGHT_GREEN("\u001B[92m"),
|
||||||
|
BRIGHT_YELLOW("\u001B[93m"),
|
||||||
|
BRIGHT_BLUE("\u001B[94m"),
|
||||||
|
BRIGHT_MAGENTA("\u001B[95m"),
|
||||||
|
BRIGHT_CYAN("\u001B[96m"),
|
||||||
|
BRIGHT_WHITE("\u001B[97m"),
|
||||||
|
|
||||||
|
// Extended 256-color palette (some popular ones)
|
||||||
|
ORANGE("\u001B[38;5;208m"),
|
||||||
|
PURPLE("\u001B[38;5;129m"),
|
||||||
|
PINK("\u001B[38;5;205m"),
|
||||||
|
LIME("\u001B[38;5;154m"),
|
||||||
|
TEAL("\u001B[38;5;80m"),
|
||||||
|
NAVY("\u001B[38;5;17m"),
|
||||||
|
MAROON("\u001B[38;5;88m"),
|
||||||
|
OLIVE("\u001B[38;5;100m"),
|
||||||
|
SILVER("\u001B[38;5;248m"),
|
||||||
|
GOLD("\u001B[38;5;220m"),
|
||||||
|
CORAL("\u001B[38;5;209m"),
|
||||||
|
SALMON("\u001B[38;5;210m"),
|
||||||
|
KHAKI("\u001B[38;5;185m"),
|
||||||
|
VIOLET("\u001B[38;5;177m"),
|
||||||
|
INDIGO("\u001B[38;5;54m"),
|
||||||
|
TURQUOISE("\u001B[38;5;80m"),
|
||||||
|
CRIMSON("\u001B[38;5;196m"),
|
||||||
|
FOREST_GREEN("\u001B[38;5;22m"),
|
||||||
|
ROYAL_BLUE("\u001B[38;5;21m"),
|
||||||
|
DARK_ORANGE("\u001B[38;5;166m"),
|
||||||
|
LIGHT_GRAY("\u001B[38;5;250m"),
|
||||||
|
DARK_GRAY("\u001B[38;5;240m"),
|
||||||
|
|
||||||
|
// Pastel colors
|
||||||
|
PASTEL_PINK("\u001B[38;5;217m"),
|
||||||
|
PASTEL_BLUE("\u001B[38;5;153m"),
|
||||||
|
PASTEL_GREEN("\u001B[38;5;157m"),
|
||||||
|
PASTEL_YELLOW("\u001B[38;5;229m"),
|
||||||
|
PASTEL_PURPLE("\u001B[38;5;183m"),
|
||||||
|
PASTEL_ORANGE("\u001B[38;5;223m"),
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
BOLD("\u001B[1m"),
|
||||||
|
DIM("\u001B[2m"),
|
||||||
|
ITALIC("\u001B[3m"),
|
||||||
|
UNDERLINE("\u001B[4m"),
|
||||||
|
BLINK("\u001B[5m"),
|
||||||
|
REVERSE("\u001B[7m"),
|
||||||
|
STRIKETHROUGH("\u001B[9m"),
|
||||||
|
|
||||||
|
// Background colors
|
||||||
|
BG_BLACK("\u001B[40m"),
|
||||||
|
BG_RED("\u001B[41m"),
|
||||||
|
BG_GREEN("\u001B[42m"),
|
||||||
|
BG_YELLOW("\u001B[43m"),
|
||||||
|
BG_BLUE("\u001B[44m"),
|
||||||
|
BG_MAGENTA("\u001B[45m"),
|
||||||
|
BG_CYAN("\u001B[46m"),
|
||||||
|
BG_WHITE("\u001B[47m"),
|
||||||
|
|
||||||
|
// Semantic presets
|
||||||
|
ERROR("${BRIGHT_RED.code}${BOLD.code}"),
|
||||||
|
WARN("${YELLOW.code}${BOLD.code}"),
|
||||||
|
INFO(CYAN.code),
|
||||||
|
DEBUG("${DIM.code}${GRAY.code}"),
|
||||||
|
SUCCESS("${BRIGHT_GREEN.code}${BOLD.code}"),
|
||||||
|
|
||||||
|
// File/UI semantic colors
|
||||||
|
FILENAME("${BRIGHT_BLUE.code}${BOLD.code}"),
|
||||||
|
HEADING("${BRIGHT_MAGENTA.code}${BOLD.code}"),
|
||||||
|
SUBHEADING("${MAGENTA.code}${BOLD.code}"),
|
||||||
|
EMPHASIS("${BRIGHT_YELLOW.code}${BOLD.code}"),
|
||||||
|
HIGHLIGHT("${BG_YELLOW.code}${BLACK.code}"),
|
||||||
|
LINK("${BRIGHT_CYAN.code}${UNDERLINE.code}"),
|
||||||
|
CODE(BRIGHT_GREEN.code),
|
||||||
|
COMMENT("${DIM.code}${GRAY.code}"),
|
||||||
|
KEYWORD(BRIGHT_MAGENTA.code),
|
||||||
|
STRING(GREEN.code),
|
||||||
|
NUMBER(BRIGHT_BLUE.code),
|
||||||
|
OPERATOR(BRIGHT_RED.code),
|
||||||
|
|
||||||
|
// Status colors
|
||||||
|
ACTIVE(BRIGHT_GREEN.code),
|
||||||
|
INACTIVE("${DIM.code}${GRAY.code}"),
|
||||||
|
PENDING(YELLOW.code),
|
||||||
|
FAILED(BRIGHT_RED.code),
|
||||||
|
PASSED(BRIGHT_GREEN.code),
|
||||||
|
SKIPPED(BRIGHT_YELLOW.code),
|
||||||
|
|
||||||
|
// UI Elements
|
||||||
|
BORDER(DARK_GRAY.code),
|
||||||
|
MENU_ITEM(BRIGHT_WHITE.code),
|
||||||
|
SELECTED("${BG_BLUE.code}${BRIGHT_WHITE.code}"),
|
||||||
|
DISABLED("${DIM.code}${GRAY.code}"),
|
||||||
|
|
||||||
|
// Log levels
|
||||||
|
TRACE("${DIM.code}${LIGHT_GRAY.code}"),
|
||||||
|
VERBOSE(GRAY.code),
|
||||||
|
FATAL("${BG_RED.code}${BRIGHT_WHITE.code}${BOLD.code}");
|
||||||
|
|
||||||
|
fun bold() = DerivedColor("${BOLD.code}$code")
|
||||||
|
fun dim() = DerivedColor("${DIM.code}$code")
|
||||||
|
fun italic() = DerivedColor("${ITALIC.code}$code")
|
||||||
|
fun underline() = DerivedColor("${UNDERLINE.code}$code")
|
||||||
|
fun strikethrough() = DerivedColor("${STRIKETHROUGH.code}$code")
|
||||||
|
fun reverse() = DerivedColor("${REVERSE.code}$code")
|
||||||
|
fun blink() = DerivedColor("${BLINK.code}$code")
|
||||||
|
|
||||||
|
// Combine with background
|
||||||
|
fun onBackground(bgColor: Color) = DerivedColor("${bgColor.code}$code")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrapper for modified colors */
|
||||||
|
class DerivedColor(val combinedCode: String) {
|
||||||
|
fun bold() = DerivedColor("${Color.BOLD.code}$combinedCode")
|
||||||
|
fun dim() = DerivedColor("${Color.DIM.code}$combinedCode")
|
||||||
|
fun italic() = DerivedColor("${Color.ITALIC.code}$combinedCode")
|
||||||
|
fun underline() = DerivedColor("${Color.UNDERLINE.code}$combinedCode")
|
||||||
|
fun strikethrough() = DerivedColor("${Color.STRIKETHROUGH.code}$combinedCode")
|
||||||
|
fun reverse() = DerivedColor("${Color.REVERSE.code}$combinedCode")
|
||||||
|
fun blink() = DerivedColor("${Color.BLINK.code}$combinedCode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global enable/disable
|
||||||
|
private var globalColorsEnabled = true
|
||||||
|
fun setGlobalColorsEnabled(enabled: Boolean) { globalColorsEnabled = enabled }
|
||||||
|
fun areGlobalColorsEnabled(): Boolean = globalColorsEnabled
|
||||||
|
|
||||||
|
// colorize() overloads
|
||||||
|
fun colorize(text: String, color: Color): String =
|
||||||
|
if (globalColorsEnabled) "${color.code}$text${Color.RESET.code}" else text
|
||||||
|
|
||||||
|
fun colorize(text: String, color: DerivedColor): String =
|
||||||
|
if (globalColorsEnabled) "${color.combinedCode}$text${Color.RESET.code}" else text
|
||||||
|
|
||||||
|
// RGB color support (for terminals that support it)
|
||||||
|
fun rgb(r: Int, g: Int, b: Int): DerivedColor =
|
||||||
|
DerivedColor("\u001B[38;2;$r;$g;${b}m")
|
||||||
|
|
||||||
|
fun bgRgb(r: Int, g: Int, b: Int): DerivedColor =
|
||||||
|
DerivedColor("\u001B[48;2;$r;$g;${b}m")
|
||||||
|
|
||||||
|
// Hex color support
|
||||||
|
fun hex(hexColor: String): DerivedColor {
|
||||||
|
val hex = hexColor.removePrefix("#")
|
||||||
|
val r = hex.take(2).toInt(16)
|
||||||
|
val g = hex.substring(2, 4).toInt(16)
|
||||||
|
val b = hex.substring(4, 6).toInt(16)
|
||||||
|
return rgb(r, g, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Semantic convenience helpers
|
||||||
|
// --------------------------------------------------------
|
||||||
|
fun filename(text: String) = colorize(text, Color.FILENAME)
|
||||||
|
fun heading(text: String) = colorize(text, Color.HEADING)
|
||||||
|
fun subheading(text: String) = colorize(text, Color.SUBHEADING)
|
||||||
|
fun emphasis(text: String) = colorize(text, Color.EMPHASIS)
|
||||||
|
fun highlight(text: String) = colorize(text, Color.HIGHLIGHT)
|
||||||
|
fun link(text: String) = colorize(text, Color.LINK)
|
||||||
|
fun code(text: String) = colorize(text, Color.CODE)
|
||||||
|
fun comment(text: String) = colorize(text, Color.COMMENT)
|
||||||
|
fun keyword(text: String) = colorize(text, Color.KEYWORD)
|
||||||
|
fun string(text: String) = colorize(text, Color.STRING)
|
||||||
|
fun number(text: String) = colorize(text, Color.NUMBER)
|
||||||
|
fun operator(text: String) = colorize(text, Color.OPERATOR)
|
||||||
|
|
||||||
|
// Status helpers
|
||||||
|
fun active(text: String) = colorize(text, Color.ACTIVE)
|
||||||
|
fun inactive(text: String) = colorize(text, Color.INACTIVE)
|
||||||
|
fun pending(text: String) = colorize(text, Color.PENDING)
|
||||||
|
fun failed(text: String) = colorize(text, Color.FAILED)
|
||||||
|
fun passed(text: String) = colorize(text, Color.PASSED)
|
||||||
|
fun skipped(text: String) = colorize(text, Color.SKIPPED)
|
||||||
|
fun success(text: String) = colorize(text, Color.SUCCESS)
|
||||||
|
|
||||||
|
// Log level helpers
|
||||||
|
fun trace(text: String) = colorize(text, Color.TRACE)
|
||||||
|
fun verbose(text: String) = colorize(text, Color.VERBOSE)
|
||||||
|
fun fatal(text: String) = colorize(text, Color.FATAL)
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Extended color convenience helpers
|
||||||
|
// --------------------------------------------------------
|
||||||
|
fun orange(text: String) = colorize(text, Color.ORANGE)
|
||||||
|
fun purple(text: String) = colorize(text, Color.PURPLE)
|
||||||
|
fun pink(text: String) = colorize(text, Color.PINK)
|
||||||
|
fun lime(text: String) = colorize(text, Color.LIME)
|
||||||
|
fun teal(text: String) = colorize(text, Color.TEAL)
|
||||||
|
fun navy(text: String) = colorize(text, Color.NAVY)
|
||||||
|
fun maroon(text: String) = colorize(text, Color.MAROON)
|
||||||
|
fun olive(text: String) = colorize(text, Color.OLIVE)
|
||||||
|
fun silver(text: String) = colorize(text, Color.SILVER)
|
||||||
|
fun gold(text: String) = colorize(text, Color.GOLD)
|
||||||
|
fun coral(text: String) = colorize(text, Color.CORAL)
|
||||||
|
fun salmon(text: String) = colorize(text, Color.SALMON)
|
||||||
|
fun khaki(text: String) = colorize(text, Color.KHAKI)
|
||||||
|
fun violet(text: String) = colorize(text, Color.VIOLET)
|
||||||
|
fun indigo(text: String) = colorize(text, Color.INDIGO)
|
||||||
|
fun turquoise(text: String) = colorize(text, Color.TURQUOISE)
|
||||||
|
fun crimson(text: String) = colorize(text, Color.CRIMSON)
|
||||||
|
fun forestGreen(text: String) = colorize(text, Color.FOREST_GREEN)
|
||||||
|
fun royalBlue(text: String) = colorize(text, Color.ROYAL_BLUE)
|
||||||
|
fun darkOrange(text: String) = colorize(text, Color.DARK_ORANGE)
|
||||||
|
fun lightGray(text: String) = colorize(text, Color.LIGHT_GRAY)
|
||||||
|
fun darkGray(text: String) = colorize(text, Color.DARK_GRAY)
|
||||||
|
|
||||||
|
// Pastel helpers
|
||||||
|
fun pastelPink(text: String) = colorize(text, Color.PASTEL_PINK)
|
||||||
|
fun pastelBlue(text: String) = colorize(text, Color.PASTEL_BLUE)
|
||||||
|
fun pastelGreen(text: String) = colorize(text, Color.PASTEL_GREEN)
|
||||||
|
fun pastelYellow(text: String) = colorize(text, Color.PASTEL_YELLOW)
|
||||||
|
fun pastelPurple(text: String) = colorize(text, Color.PASTEL_PURPLE)
|
||||||
|
fun pastelOrange(text: String) = colorize(text, Color.PASTEL_ORANGE)
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Original convenience helpers (keeping all existing ones)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
fun bold(text: String) = colorize(text, Color.WHITE.bold())
|
||||||
|
fun dim(text: String) = colorize(text, Color.GRAY)
|
||||||
|
fun black(text: String) = colorize(text, Color.BLACK)
|
||||||
|
fun red(text: String) = colorize(text, Color.RED)
|
||||||
|
fun green(text: String) = colorize(text, Color.GREEN)
|
||||||
|
fun yellow(text: String) = colorize(text, Color.YELLOW)
|
||||||
|
fun blue(text: String) = colorize(text, Color.BLUE)
|
||||||
|
fun magenta(text: String) = colorize(text, Color.MAGENTA)
|
||||||
|
fun cyan(text: String) = colorize(text, Color.CYAN)
|
||||||
|
fun white(text: String) = colorize(text, Color.WHITE)
|
||||||
|
fun gray(text: String) = colorize(text, Color.GRAY)
|
||||||
|
|
||||||
|
fun brightBlack(text: String) = colorize(text, Color.BRIGHT_BLACK)
|
||||||
|
fun brightRed(text: String) = colorize(text, Color.BRIGHT_RED)
|
||||||
|
fun brightGreen(text: String) = colorize(text, Color.BRIGHT_GREEN)
|
||||||
|
fun brightYellow(text: String) = colorize(text, Color.BRIGHT_YELLOW)
|
||||||
|
fun brightBlue(text: String) = colorize(text, Color.BRIGHT_BLUE)
|
||||||
|
fun brightMagenta(text: String) = colorize(text, Color.BRIGHT_MAGENTA)
|
||||||
|
fun brightCyan(text: String) = colorize(text, Color.BRIGHT_CYAN)
|
||||||
|
fun brightWhite(text: String) = colorize(text, Color.BRIGHT_WHITE)
|
||||||
|
|
||||||
|
// Presets
|
||||||
|
fun error(text: String) = colorize(text, Color.ERROR)
|
||||||
|
fun warn(text: String) = colorize(text, Color.WARN)
|
||||||
|
fun info(text: String) = colorize(text, Color.INFO)
|
||||||
|
fun debug(text: String) = colorize(text, Color.DEBUG)
|
||||||
|
|
||||||
|
// Bold helpers (keeping all existing ones)
|
||||||
|
fun boldBlack(text: String) = colorize(text, Color.BLACK.bold())
|
||||||
|
fun boldRed(text: String) = colorize(text, Color.RED.bold())
|
||||||
|
fun boldGreen(text: String) = colorize(text, Color.GREEN.bold())
|
||||||
|
fun boldYellow(text: String) = colorize(text, Color.YELLOW.bold())
|
||||||
|
fun boldBlue(text: String) = colorize(text, Color.BLUE.bold())
|
||||||
|
fun boldMagenta(text: String) = colorize(text, Color.MAGENTA.bold())
|
||||||
|
fun boldCyan(text: String) = colorize(text, Color.CYAN.bold())
|
||||||
|
fun boldWhite(text: String) = colorize(text, Color.WHITE.bold())
|
||||||
|
|
||||||
|
fun boldBrightBlack(text: String) = colorize(text, Color.BRIGHT_BLACK.bold())
|
||||||
|
fun boldBrightRed(text: String) = colorize(text, Color.BRIGHT_RED.bold())
|
||||||
|
fun boldBrightGreen(text: String) = colorize(text, Color.BRIGHT_GREEN.bold())
|
||||||
|
fun boldBrightYellow(text: String) = colorize(text, Color.BRIGHT_YELLOW.bold())
|
||||||
|
fun boldBrightBlue(text: String) = colorize(text, Color.BRIGHT_BLUE.bold())
|
||||||
|
fun boldBrightMagenta(text: String) = colorize(text, Color.BRIGHT_MAGENTA.bold())
|
||||||
|
fun boldBrightCyan(text: String) = colorize(text, Color.BRIGHT_CYAN.bold())
|
||||||
|
fun boldBrightWhite(text: String) = colorize(text, Color.BRIGHT_WHITE.bold())
|
||||||
|
|
||||||
|
// Dim helpers (keeping all existing ones)
|
||||||
|
fun dimBlack(text: String) = colorize(text, Color.BLACK.dim())
|
||||||
|
fun dimRed(text: String) = colorize(text, Color.RED.dim())
|
||||||
|
fun dimGreen(text: String) = colorize(text, Color.GREEN.dim())
|
||||||
|
fun dimYellow(text: String) = colorize(text, Color.YELLOW.dim())
|
||||||
|
fun dimBlue(text: String) = colorize(text, Color.BLUE.dim())
|
||||||
|
fun dimMagenta(text: String) = colorize(text, Color.MAGENTA.dim())
|
||||||
|
fun dimCyan(text: String) = colorize(text, Color.CYAN.dim())
|
||||||
|
fun dimWhite(text: String) = colorize(text, Color.WHITE.dim())
|
||||||
|
|
||||||
|
fun dimBrightBlack(text: String) = colorize(text, Color.BRIGHT_BLACK.dim())
|
||||||
|
fun dimBrightRed(text: String) = colorize(text, Color.BRIGHT_RED.dim())
|
||||||
|
fun dimBrightGreen(text: String) = colorize(text, Color.BRIGHT_GREEN.dim())
|
||||||
|
fun dimBrightYellow(text: String) = colorize(text, Color.BRIGHT_YELLOW.dim())
|
||||||
|
fun dimBrightBlue(text: String) = colorize(text, Color.BRIGHT_BLUE.dim())
|
||||||
|
fun dimBrightMagenta(text: String) = colorize(text, Color.BRIGHT_MAGENTA.dim())
|
||||||
|
fun dimBrightCyan(text: String) = colorize(text, Color.BRIGHT_CYAN.dim())
|
||||||
|
fun dimBrightWhite(text: String) = colorize(text, Color.BRIGHT_WHITE.dim())
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Utility functions
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
// Create a gradient effect
|
||||||
|
fun gradient(text: String, startColor: Color, endColor: Color): String {
|
||||||
|
if (!globalColorsEnabled || text.isEmpty()) return text
|
||||||
|
|
||||||
|
val length = text.length
|
||||||
|
if (length == 1) return colorize(text, startColor)
|
||||||
|
|
||||||
|
return text.mapIndexed { index, char ->
|
||||||
|
val ratio = index.toFloat() / (length - 1)
|
||||||
|
// Simple interpolation between colors (this is a basic implementation)
|
||||||
|
val color = if (ratio < 0.5f) startColor else endColor
|
||||||
|
colorize(char.toString(), color)
|
||||||
|
}.joinToString("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rainbow effect
|
||||||
|
fun rainbow(text: String): String {
|
||||||
|
if (!globalColorsEnabled) return text
|
||||||
|
|
||||||
|
val colors = listOf(
|
||||||
|
Color.RED, Color.ORANGE, Color.YELLOW, Color.GREEN,
|
||||||
|
Color.CYAN, Color.BLUE, Color.MAGENTA
|
||||||
|
)
|
||||||
|
|
||||||
|
return text.mapIndexed { index, char ->
|
||||||
|
val colorIndex = index % colors.size
|
||||||
|
colorize(char.toString(), colors[colorIndex])
|
||||||
|
}.joinToString("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip all ANSI codes
|
||||||
|
fun stripColors(text: String): String = text.replace(Regex("\u001B\\[[0-9;]*m"), "")
|
||||||
|
|
||||||
|
// Get length without ANSI codes
|
||||||
|
fun getDisplayLength(text: String): Int = stripColors(text).length
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
@@ -25,6 +25,10 @@ class Parser(
|
|||||||
* @param args Array of command-line arguments
|
* @param args Array of command-line arguments
|
||||||
*/
|
*/
|
||||||
fun parse(args: Array<String>) {
|
fun parse(args: Array<String>) {
|
||||||
|
if (handleVersionFlag(args)) {
|
||||||
|
return // Exit after showing version
|
||||||
|
}
|
||||||
|
|
||||||
if (args.isEmpty()) {
|
if (args.isEmpty()) {
|
||||||
if (config.helpOnEmpty) {
|
if (config.helpOnEmpty) {
|
||||||
printGlobalHelp()
|
printGlobalHelp()
|
||||||
@@ -32,12 +36,6 @@ class Parser(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for global help
|
|
||||||
if (args.contains("--help") || args.contains("-h")) {
|
|
||||||
printGlobalHelp()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val cmdName = args[0]
|
val cmdName = args[0]
|
||||||
val cmd = findCommand(cmdName)
|
val cmd = findCommand(cmdName)
|
||||||
|
|
||||||
@@ -49,19 +47,23 @@ class Parser(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for command specific help
|
// Check for help, global or command
|
||||||
if (args.contains("--help") || args.contains("-h")) {
|
if ("--help" in args || "-h" in args) {
|
||||||
cmd.printHelp()
|
cmd.printHelp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for no-color
|
||||||
|
if ("--no-color" in args) {
|
||||||
|
Colors.setGlobalColorsEnabled(false)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
parseCommandArgs(cmd, args.sliceArray(1 until args.size))
|
parseCommandArgs(cmd, args.sliceArray(1 ..< args.size))
|
||||||
validateRequiredOptions(cmd)
|
validateRequiredOptions(cmd)
|
||||||
cmd.execute()
|
cmd.execute()
|
||||||
} catch (e: ArgumentParseException) {
|
} catch (e: ArgumentParseException) {
|
||||||
printError(e.message ?: "Parse error")
|
handleParseError(e, cmd)
|
||||||
cmd.printHelp()
|
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,7 +76,7 @@ class Parser(
|
|||||||
return commands.firstOrNull { cmd ->
|
return commands.firstOrNull { cmd ->
|
||||||
val cmdName = if (config.caseSensitive) cmd.name else cmd.name.lowercase()
|
val cmdName = if (config.caseSensitive) cmd.name else cmd.name.lowercase()
|
||||||
val aliases = if (config.caseSensitive) cmd.aliases else cmd.aliases.map { it.lowercase() }
|
val aliases = if (config.caseSensitive) cmd.aliases else cmd.aliases.map { it.lowercase() }
|
||||||
cmdName == searchName || aliases.contains(searchName)
|
cmdName == searchName || searchName in aliases
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +90,9 @@ class Parser(
|
|||||||
while (i < args.size) {
|
while (i < args.size) {
|
||||||
val arg = args[i]
|
val arg = args[i]
|
||||||
|
|
||||||
|
if ("--help" in arg || "-h" in arg) i++
|
||||||
|
if ("--no-color" in arg) i++
|
||||||
|
|
||||||
when {
|
when {
|
||||||
arg.startsWith("--") -> {
|
arg.startsWith("--") -> {
|
||||||
val key = arg.removePrefix("--")
|
val key = arg.removePrefix("--")
|
||||||
@@ -115,6 +120,7 @@ class Parser(
|
|||||||
private fun parseLongOption(cmd: Subcommand, key: String, args: Array<String>, index: Int): Int {
|
private fun parseLongOption(cmd: Subcommand, key: String, args: Array<String>, index: Int): Int {
|
||||||
val option = cmd.options.firstOrNull { it.longName == key }
|
val option = cmd.options.firstOrNull { it.longName == key }
|
||||||
val flag = cmd.flags.firstOrNull { it.longName == key }
|
val flag = cmd.flags.firstOrNull { it.longName == key }
|
||||||
|
val optionalOption = cmd.optionalOptions.firstOrNull { it.longName == key }
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
option != null -> {
|
option != null -> {
|
||||||
@@ -129,6 +135,18 @@ class Parser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 != null -> {
|
||||||
flag.setFlag()
|
flag.setFlag()
|
||||||
index
|
index
|
||||||
@@ -153,10 +171,13 @@ class Parser(
|
|||||||
if (key.length > 1) {
|
if (key.length > 1) {
|
||||||
key.forEach { char ->
|
key.forEach { char ->
|
||||||
val flag = cmd.flags.firstOrNull { it.shortName == char.toString() }
|
val flag = cmd.flags.firstOrNull { it.shortName == char.toString() }
|
||||||
if (flag != null) {
|
val optionalOption = cmd.optionalOptions.firstOrNull { it.shortName == char.toString() }
|
||||||
flag.setFlag()
|
|
||||||
} else if (config.strictMode) {
|
when {
|
||||||
throw ArgumentParseException("Unknown flag -$char")
|
flag != null -> flag.setFlag()
|
||||||
|
optionalOption != null -> optionalOption.setAsFlag()
|
||||||
|
config.strictMode -> throw ArgumentParseException("Unknown flag -$char")
|
||||||
|
else -> printWarning("Unknown flag -$char")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return index
|
return index
|
||||||
@@ -164,6 +185,7 @@ class Parser(
|
|||||||
|
|
||||||
val option = cmd.options.firstOrNull { it.shortName == key }
|
val option = cmd.options.firstOrNull { it.shortName == key }
|
||||||
val flag = cmd.flags.firstOrNull { it.shortName == key }
|
val flag = cmd.flags.firstOrNull { it.shortName == key }
|
||||||
|
val optionalOption = cmd.optionalOptions.firstOrNull { it.shortName == key }
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
option != null -> {
|
option != null -> {
|
||||||
@@ -178,6 +200,19 @@ class Parser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 != null -> {
|
||||||
flag.setFlag()
|
flag.setFlag()
|
||||||
index
|
index
|
||||||
@@ -225,9 +260,22 @@ class Parser(
|
|||||||
*/
|
*/
|
||||||
private fun validateRequiredOptions(cmd: Subcommand) {
|
private fun validateRequiredOptions(cmd: Subcommand) {
|
||||||
val missingRequired = cmd.options.filter { it.required && it.value == null }
|
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()) {
|
if (missingRequired.isNotEmpty()) {
|
||||||
val missing = missingRequired.joinToString(", ") { "--${it.longName}" }
|
val missing = missingRequired.joinToString(", ") { "--${it.longName}" }
|
||||||
throw ArgumentParseException("Missing required options: $missing")
|
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(", "))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,15 +283,17 @@ class Parser(
|
|||||||
* Print global help menu
|
* Print global help menu
|
||||||
*/
|
*/
|
||||||
private fun printGlobalHelp() {
|
private fun printGlobalHelp() {
|
||||||
println(colorize("Usage: $programName <command> [options]", Color.BOLD))
|
val versionInfo = config.programVersion?.let { " ${Colors.dimBlue("(v$it)")}" } ?: ""
|
||||||
|
println(Colors.boldWhite("Usage: $programName$versionInfo <command> [options]"))
|
||||||
println()
|
println()
|
||||||
println(colorize("Commands:", Color.BOLD))
|
println(Colors.boldWhite("Commands:"))
|
||||||
commands.forEach { cmd ->
|
commands.forEach { cmd ->
|
||||||
val aliases = if (cmd.aliases.isNotEmpty()) " (${cmd.aliases.joinToString(", ")})" else ""
|
val aliases = if (cmd.aliases.isNotEmpty()) " (${cmd.aliases.joinToString(", ")})" else ""
|
||||||
println(" ${colorize(cmd.name, Color.GREEN)}$aliases")
|
println(" ${cmd.name}${Colors.dimBlue(aliases)}")
|
||||||
if (cmd.description.isNotEmpty()) {
|
if (cmd.description.isNotEmpty()) {
|
||||||
println(" ${cmd.description}")
|
println(Colors.dimMagenta(" ${cmd.description}"))
|
||||||
}
|
}
|
||||||
|
println()
|
||||||
}
|
}
|
||||||
println()
|
println()
|
||||||
println("Use `$programName <command> --help` for more information about a command.")
|
println("Use `$programName <command> --help` for more information about a command.")
|
||||||
@@ -253,36 +303,57 @@ class Parser(
|
|||||||
* Print error message with optional coloring
|
* Print error message with optional coloring
|
||||||
*/
|
*/
|
||||||
private fun printError(message: String) {
|
private fun printError(message: String) {
|
||||||
println(colorize("Error: $message", Color.RED))
|
println(Colors.error("Error: $message"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Print warning message with optional coloring
|
* Print warning message with optional coloring
|
||||||
*/
|
*/
|
||||||
private fun printWarning(message: String) {
|
private fun printWarning(message: String) {
|
||||||
println(colorize("Warning: $message", Color.YELLOW))
|
println(Colors.warn("Warning: $message"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply color to text if colors are enabled
|
* Handles parse errors
|
||||||
|
*
|
||||||
|
* @throw ArgumentParseException if in debug mode
|
||||||
*/
|
*/
|
||||||
private fun colorize(text: String, color: Color): String {
|
private fun handleParseError(e: ArgumentParseException, cmd: Subcommand) {
|
||||||
return if (config.colorsEnabled) {
|
printError(e.message ?: "Parse error")
|
||||||
"${color.code}$text${Color.RESET.code}"
|
cmd.printHelp()
|
||||||
} else {
|
|
||||||
text
|
// Only show stack trace in debug mode
|
||||||
|
if (System.getProperty("debug") == "true") {
|
||||||
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exit gracefully instead of throwing
|
||||||
|
// kotlin.system.exitProcess(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ANSI color codes for terminal output
|
* Check if version flag is present and handle it
|
||||||
*/
|
*/
|
||||||
private enum class Color(val code: String) {
|
private fun handleVersionFlag(args: Array<String>): Boolean {
|
||||||
RESET("\u001B[0m"),
|
if (config.programVersion == null) return false
|
||||||
RED("\u001B[31m"),
|
|
||||||
GREEN("\u001B[32m"),
|
val versionFlags = listOf("--${config.versionLongName}", "-${config.versionShortName}")
|
||||||
YELLOW("\u001B[33m"),
|
|
||||||
BOLD("\u001B[1m")
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,17 @@ data class ParserConfig(
|
|||||||
val helpOnEmpty: Boolean = true, // Show help when no args provided
|
val helpOnEmpty: Boolean = true, // Show help when no args provided
|
||||||
val caseSensitive: Boolean = true,
|
val caseSensitive: Boolean = true,
|
||||||
val allowAbbreviations: Boolean = false, // Allow partial option matching
|
val allowAbbreviations: Boolean = false, // Allow partial option matching
|
||||||
val programVersion: String? = null // Adds --version and -v flag if set
|
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 {
|
companion object {
|
||||||
val DEFAULT = ParserConfig()
|
val DEFAULT = ParserConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
Colors.setGlobalColorsEnabled(colorsEnabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ abstract class Subcommand(
|
|||||||
private val _options = mutableListOf<Option<*>>()
|
private val _options = mutableListOf<Option<*>>()
|
||||||
private val _flags = mutableListOf<Flag>()
|
private val _flags = mutableListOf<Flag>()
|
||||||
private val _arguments = mutableListOf<Argument<*>>()
|
private val _arguments = mutableListOf<Argument<*>>()
|
||||||
|
private val _optionalOptions = mutableListOf<OptionalOption>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
require(name.isNotBlank()) { "Subcommand name cannot be blank" }
|
require(name.isNotBlank()) { "Subcommand name cannot be blank" }
|
||||||
@@ -30,6 +31,7 @@ abstract class Subcommand(
|
|||||||
is Option<*> -> _options += prop
|
is Option<*> -> _options += prop
|
||||||
is Flag -> _flags += prop
|
is Flag -> _flags += prop
|
||||||
is Argument<*> -> _arguments += prop
|
is Argument<*> -> _arguments += prop
|
||||||
|
is OptionalOption -> _optionalOptions += prop
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ abstract class Subcommand(
|
|||||||
val options: List<Option<*>> get() = _options
|
val options: List<Option<*>> get() = _options
|
||||||
val flags: List<Flag> get() = _flags
|
val flags: List<Flag> get() = _flags
|
||||||
val arguments: List<Argument<*>> get() = _arguments
|
val arguments: List<Argument<*>> get() = _arguments
|
||||||
|
val optionalOptions: List<OptionalOption> get() = _optionalOptions
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute this subcommand - must be implemented by subclasses
|
* Execute this subcommand - must be implemented by subclasses
|
||||||
@@ -47,47 +50,67 @@ abstract class Subcommand(
|
|||||||
* Print help information for this subcommand
|
* Print help information for this subcommand
|
||||||
*/
|
*/
|
||||||
fun printHelp() {
|
fun printHelp() {
|
||||||
println("Usage: $name [options]${if (arguments.isNotEmpty()) " ${arguments.joinToString(" ") { "<${it.name}>" }}" else ""}")
|
println(Colors.boldWhite("Usage: $name ${"[options]"}${if (arguments.isNotEmpty()) " ${arguments.joinToString(" ") { if (it.required) "<${it.name}>" else "[${it.name}]" }}" else ""}"))
|
||||||
|
|
||||||
if (description.isNotEmpty()) {
|
if (description.isNotEmpty()) {
|
||||||
println()
|
println()
|
||||||
println(description)
|
println(Colors.boldMagenta(description))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.isNotEmpty()) {
|
if (options.isNotEmpty()) {
|
||||||
println()
|
println()
|
||||||
println("Options:")
|
println(Colors.boldWhite("Options:"))
|
||||||
options.forEach { option ->
|
options.forEach { option ->
|
||||||
val shortName = option.shortName?.let { "-$it, " } ?: " "
|
val shortName = option.shortName?.let { "-$it, " } ?: " "
|
||||||
val required = if (option.required) " (required)" else ""
|
val required = if (option.required) Colors.dimBlue(" (required)") else ""
|
||||||
val defaultVal = option.getValueOrDefault()?.let { " [default: $it]" } ?: ""
|
val defaultVal = option.getValueOrDefault()?.let { Colors.boldBlue(" [default: $it]") } ?: ""
|
||||||
println(" $shortName--${option.longName}")
|
val typeInfo = Colors.yellow(getTypeInfo(option.type))
|
||||||
|
|
||||||
|
println(" $shortName--${option.longName}${typeInfo}")
|
||||||
option.description?.let { desc ->
|
option.description?.let { desc ->
|
||||||
println(" $desc$required$defaultVal")
|
println(Colors.dimMagenta(" $desc$required$defaultVal"))
|
||||||
}
|
}
|
||||||
|
println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optionalOptions.isNotEmpty()) {
|
||||||
|
println()
|
||||||
|
println(Colors.boldWhite("Optional Value Options:"))
|
||||||
|
optionalOptions.forEach { option ->
|
||||||
|
val shortName = option.shortName?.let { "-$it, " } ?: " "
|
||||||
|
println(" $shortName--${option.longName} [value]")
|
||||||
|
option.description?.let { desc ->
|
||||||
|
println(Colors.dimMagenta(" $desc (can be used as flag or with value)"))
|
||||||
|
}
|
||||||
|
println()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (flags.isNotEmpty()) {
|
if (flags.isNotEmpty()) {
|
||||||
println()
|
println()
|
||||||
println("Flags:")
|
println(Colors.boldWhite("Flags:"))
|
||||||
flags.forEach { flag ->
|
flags.forEach { flag ->
|
||||||
val shortName = flag.shortName?.let { "-$it, " } ?: " "
|
val shortName = flag.shortName?.let { "-$it, " } ?: " "
|
||||||
println(" $shortName--${flag.longName}")
|
println(" $shortName--${flag.longName}")
|
||||||
flag.description?.let { desc ->
|
flag.description?.let { desc ->
|
||||||
println(" $desc")
|
println(Colors.dimMagenta(" $desc"))
|
||||||
}
|
}
|
||||||
|
println()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (arguments.isNotEmpty()) {
|
if (arguments.isNotEmpty()) {
|
||||||
println()
|
println()
|
||||||
println("Arguments:")
|
println(Colors.boldWhite("Arguments:"))
|
||||||
arguments.forEach { arg ->
|
arguments.forEach { arg ->
|
||||||
val required = if (arg.required) " (required)" else " (optional)"
|
val required = Colors.dimBlue(if (arg.required) " (required)" else " (optional)")
|
||||||
println(" ${arg.name}$required")
|
val typeInfo = Colors.yellow(getTypeInfo(arg.type))
|
||||||
|
println(" ${arg.name}$typeInfo$required")
|
||||||
arg.description?.let { desc ->
|
arg.description?.let { desc ->
|
||||||
println(" $desc")
|
println(Colors.dimMagenta(" $desc"))
|
||||||
}
|
}
|
||||||
|
println()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,4 +130,20 @@ abstract class Subcommand(
|
|||||||
|
|
||||||
return errors
|
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>"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import org.junit.jupiter.params.provider.ValueSource
|
|||||||
import org.kargs.ArgType
|
import org.kargs.ArgType
|
||||||
import org.kargs.ArgumentParseException
|
import org.kargs.ArgumentParseException
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.io.path.createTempFile
|
||||||
|
import kotlin.io.path.createTempDirectory
|
||||||
|
|
||||||
class ArgTypeTest {
|
class ArgTypeTest {
|
||||||
|
|
||||||
@@ -103,5 +105,192 @@ class ArgTypeTest {
|
|||||||
val type = ArgType.choice("apple", "banana", "cherry")
|
val type = ArgType.choice("apple", "banana", "cherry")
|
||||||
assertEquals("one of: apple, banana, cherry", type.getValidationDescription())
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user