6 Commits
v1.0.2 ... main

Author SHA1 Message Date
Darwin Cereska
cfa7d10048 Create README.md 2025-11-24 12:27:05 -05:00
darwincereska
8d05aa4ff5 fix(bugs): bugfix 2025-11-24 10:36:57 -05:00
darwincereska
c6470be0df feat(colors): added more colors and --no-color flag 2025-11-24 10:22:43 -05:00
darwincereska
fc5786951d feat(features): added file options, fixed some things 2025-11-19 16:59:09 -05:00
darwincereska
de79b6bcd5 feat(optional-option): added optional option 2025-11-19 16:24:56 -05:00
darwincereska
afcf7f0914 fix(help-and-test): fixed help, and tests 2025-11-19 08:55:42 -05:00
10 changed files with 880 additions and 71 deletions

45
README.md Normal file
View 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")
}
}

View File

@@ -4,7 +4,7 @@ plugins {
}
group = "org.kargs"
version = "1.0.2"
version = "1.0.8"
repositories {
mavenCentral()
@@ -12,8 +12,8 @@ repositories {
dependencies {
testImplementation(kotlin("test"))
implementation(kotlin("stdlib"))
implementation(kotlin("reflect"))
// implementation(kotlin("stdlib"))
// implementation(kotlin("reflect"))
// JUnit 5 testing dependencies
testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.0")
@@ -68,3 +68,4 @@ mavenPublishing {
}
}
}

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
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
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -1,5 +1,7 @@
package org.kargs
import java.io.File
/**
* 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
*/
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 {
val intValue = value.toIntOrNull() ?: throw ArgumentParseException("`$value` is not a valid integer")
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
*/
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 {
if (value !in choices) {
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(", ")}"
}
/**
* 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 {
val String: ArgType<kotlin.String> = StringType
val Int: ArgType<kotlin.Int> = IntType
@@ -90,5 +137,35 @@ sealed class ArgType<T>(val typeName: String) {
* 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()
}
}

View 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
}

View 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
}

View File

@@ -25,6 +25,10 @@ class Parser(
* @param args Array of command-line arguments
*/
fun parse(args: Array<String>) {
if (handleVersionFlag(args)) {
return // Exit after showing version
}
if (args.isEmpty()) {
if (config.helpOnEmpty) {
printGlobalHelp()
@@ -32,12 +36,6 @@ class Parser(
return
}
// Check for global help
if (args.contains("--help") || args.contains("-h")) {
printGlobalHelp()
return
}
val cmdName = args[0]
val cmd = findCommand(cmdName)
@@ -49,20 +47,24 @@ class Parser(
return
}
// Check for command specific help
if (args.contains("--help") || args.contains("-h")) {
// Check for help, global or command
if ("--help" in args || "-h" in args) {
cmd.printHelp()
return
}
// Check for no-color
if ("--no-color" in args) {
Colors.setGlobalColorsEnabled(false)
}
try {
parseCommandArgs(cmd, args.sliceArray(1 until args.size))
parseCommandArgs(cmd, args.sliceArray(1 ..< args.size))
validateRequiredOptions(cmd)
cmd.execute()
} catch (e: ArgumentParseException) {
printError(e.message ?: "Parse error")
cmd.printHelp()
throw e
handleParseError(e, cmd)
throw e
}
}
@@ -74,7 +76,7 @@ class Parser(
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)
cmdName == searchName || searchName in aliases
}
}
@@ -88,6 +90,9 @@ class Parser(
while (i < args.size) {
val arg = args[i]
if ("--help" in arg || "-h" in arg) i++
if ("--no-color" in arg) i++
when {
arg.startsWith("--") -> {
val key = arg.removePrefix("--")
@@ -115,6 +120,7 @@ class Parser(
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 -> {
@@ -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.setFlag()
index
@@ -153,10 +171,13 @@ class Parser(
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")
val optionalOption = cmd.optionalOptions.firstOrNull { it.shortName == char.toString() }
when {
flag != null -> flag.setFlag()
optionalOption != null -> optionalOption.setAsFlag()
config.strictMode -> throw ArgumentParseException("Unknown flag -$char")
else -> printWarning("Unknown flag -$char")
}
}
return index
@@ -164,6 +185,7 @@ class Parser(
val option = cmd.options.firstOrNull { it.shortName == key }
val flag = cmd.flags.firstOrNull { it.shortName == key }
val optionalOption = cmd.optionalOptions.firstOrNull { it.shortName == key }
return when {
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.setFlag()
index
@@ -225,9 +260,22 @@ class Parser(
*/
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}" }
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
*/
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(colorize("Commands:", Color.BOLD))
println(Colors.boldWhite("Commands:"))
commands.forEach { cmd ->
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()) {
println(" ${cmd.description}")
println(Colors.dimMagenta(" ${cmd.description}"))
}
println()
}
println()
println("Use `$programName <command> --help` for more information about a command.")
@@ -253,36 +303,57 @@ class Parser(
* Print error message with optional coloring
*/
private fun printError(message: String) {
println(colorize("Error: $message", Color.RED))
println(Colors.error("Error: $message"))
}
/**
* Print warning message with optional coloring
*/
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 {
return if (config.colorsEnabled) {
"${color.code}$text${Color.RESET.code}"
} else {
text
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)
}
/**
* ANSI color codes for terminal output
* Check if version flag is present and handle it
*/
private enum class Color(val code: String) {
RESET("\u001B[0m"),
RED("\u001B[31m"),
GREEN("\u001B[32m"),
YELLOW("\u001B[33m"),
BOLD("\u001B[1m")
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")
}
}
}

View File

@@ -9,9 +9,17 @@ data class ParserConfig(
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
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()
}
init {
Colors.setGlobalColorsEnabled(colorsEnabled)
}
}

View File

@@ -16,6 +16,7 @@ abstract class Subcommand(
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" }
@@ -30,6 +31,7 @@ abstract class Subcommand(
is Option<*> -> _options += prop
is Flag -> _flags += prop
is Argument<*> -> _arguments += prop
is OptionalOption -> _optionalOptions += prop
}
}
@@ -37,6 +39,7 @@ abstract class Subcommand(
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
@@ -47,47 +50,67 @@ abstract class Subcommand(
* Print help information for this subcommand
*/
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()) {
println()
println(description)
println(Colors.boldMagenta(description))
}
if (options.isNotEmpty()) {
println()
println("Options:")
println(Colors.boldWhite("Options:"))
options.forEach { option ->
val shortName = option.shortName?.let { "-$it, " } ?: " "
val required = if (option.required) " (required)" else ""
val defaultVal = option.getValueOrDefault()?.let { " [default: $it]" } ?: ""
println(" $shortName--${option.longName}")
val required = if (option.required) Colors.dimBlue(" (required)") else ""
val defaultVal = option.getValueOrDefault()?.let { Colors.boldBlue(" [default: $it]") } ?: ""
val typeInfo = Colors.yellow(getTypeInfo(option.type))
println(" $shortName--${option.longName}${typeInfo}")
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()) {
println()
println("Flags:")
println(Colors.boldWhite("Flags:"))
flags.forEach { flag ->
val shortName = flag.shortName?.let { "-$it, " } ?: " "
println(" $shortName--${flag.longName}")
flag.description?.let { desc ->
println(" $desc")
println(Colors.dimMagenta(" $desc"))
}
println()
}
}
if (arguments.isNotEmpty()) {
println()
println("Arguments:")
println(Colors.boldWhite("Arguments:"))
arguments.forEach { arg ->
val required = if (arg.required) " (required)" else " (optional)"
println(" ${arg.name}$required")
val required = Colors.dimBlue(if (arg.required) " (required)" else " (optional)")
val typeInfo = Colors.yellow(getTypeInfo(arg.type))
println(" ${arg.name}$typeInfo$required")
arg.description?.let { desc ->
println(" $desc")
println(Colors.dimMagenta(" $desc"))
}
println()
}
}
}
@@ -107,4 +130,20 @@ abstract class Subcommand(
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>"
}
}
}

View File

@@ -7,9 +7,11 @@ 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
@@ -17,7 +19,7 @@ class ArgTypeTest {
assertEquals("", type.convert(""))
assertEquals("123", type.convert("123"))
}
@Test
fun `Int type converts valid integers`() {
val type = ArgType.Int
@@ -25,7 +27,7 @@ class ArgTypeTest {
assertEquals(-10, type.convert("-10"))
assertEquals(0, type.convert("0"))
}
@Test
fun `Int type throws on invalid input`() {
val type = ArgType.Int
@@ -33,28 +35,28 @@ class ArgTypeTest {
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
@@ -62,46 +64,233 @@ class ArgTypeTest {
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))
}
}