3 Commits

Author SHA1 Message Date
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
7 changed files with 445 additions and 35 deletions

View File

@@ -4,7 +4,7 @@ plugins {
}
group = "org.kargs"
version = "1.0.2"
version = "1.0.4"
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,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<String>("OptionalValue") {
override fun convert(value: String): String = value
override fun getValidationDescription(): String = "optional value or flag"
}
/**
* File path type with existence and permision validation
*/
class FilePath(
val mustExist: Boolean = false,
val mustBeFile: Boolean = false,
val mustBeDirectory: Boolean = false,
val mustBeReadable: Boolean = false,
val mustBeWritable: Boolean = false
) : ArgType<File>("File") {
override fun convert(value: String): File = File(value)
override fun validate(value: File): Boolean {
return when {
mustExist && !value.exists() -> false
mustBeFile && !value.isFile -> false
mustBeDirectory && !value.isDirectory -> false
mustBeReadable && !value.canRead() -> false
mustBeWritable && !value.canWrite() -> false
else -> true
}
}
override fun getValidationDescription(): String {
val conditions = mutableListOf<String>()
if (mustExist) conditions.add("must exist")
if (mustBeFile) conditions.add("must be a file")
if (mustBeDirectory) conditions.add("must be a directory")
if (mustBeReadable) conditions.add("must be readable")
if (mustBeWritable) conditions.add("must be writable")
return if (conditions.isEmpty()) "file path" else "file path (${conditions.joinToString(", ")})"
}
}
companion object {
val String: ArgType<kotlin.String> = StringType
val Int: ArgType<kotlin.Int> = IntType
@@ -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,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,7 +47,7 @@ class Parser(
return
}
// Check for command specific help
// Check for help, global or command
if (args.contains("--help") || args.contains("-h")) {
cmd.printHelp()
return
@@ -60,9 +58,8 @@ class Parser(
validateRequiredOptions(cmd)
cmd.execute()
} catch (e: ArgumentParseException) {
printError(e.message ?: "Parse error")
cmd.printHelp()
throw e
handleParseError(e, cmd)
throw e
}
}
@@ -115,6 +112,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 +127,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
@@ -225,9 +235,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,7 +258,8 @@ class Parser(
* Print global help menu
*/
private fun printGlobalHelp() {
println(colorize("Usage: $programName <command> [options]", Color.BOLD))
val versionInfo = config.programVersion?.let { " (v$it)" } ?: ""
println(colorize("Usage: $programName$versionInfo <command> [options]", Color.BOLD))
println()
println(colorize("Commands:", Color.BOLD))
commands.forEach { cmd ->
@@ -284,6 +308,49 @@ class Parser(
YELLOW("\u001B[33m"),
BOLD("\u001B[1m")
}
/**
* Handles parse errors
*
* @throw ArgumentParseException if in debug mode
*/
private fun handleParseError(e: ArgumentParseException, cmd: Subcommand) {
printError(e.message ?: "Parse error")
cmd.printHelp()
// Only show stack trace in debug mode
if (System.getProperty("debug") == "true") {
e.printStackTrace()
}
// Exit gracefully instead of throwing
// kotlin.system.exitProcess(1)
}
/**
* Check if version flag is present and handle it
*/
private fun handleVersionFlag(args: Array<String>): Boolean {
if (config.programVersion == null) return false
val versionFlags = listOf("--${config.versionLongName}", "-${config.versionShortName}")
if (args.any { it in versionFlags }) {
printVersion()
return true // Indicates version was shown, should exit
}
return false
}
/**
* Print version information
*/
private fun printVersion() {
config.programVersion?.let { version ->
println("$programName version $version")
}
}
}
/**

View File

@@ -9,7 +9,11 @@ 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()

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,7 +50,8 @@ abstract class Subcommand(
* Print help information for this subcommand
*/
fun printHelp() {
println("Usage: $name [options]${if (arguments.isNotEmpty()) " ${arguments.joinToString(" ") { "<${it.name}>" }}" else ""}")
println("Usage: $name [options]${if (arguments.isNotEmpty()) " ${arguments.joinToString(" ") { if (it.required) "<${it.name}>" else "[${it.name}]" }}" else ""}")
if (description.isNotEmpty()) {
println()
println(description)
@@ -60,13 +64,27 @@ abstract class Subcommand(
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 typeInfo = getTypeInfo(option.type)
println(" $shortName--${option.longName}${typeInfo}")
option.description?.let { desc ->
println(" $desc$required$defaultVal")
}
}
}
if (optionalOptions.isNotEmpty()) {
println()
println("Optional Value Options:")
optionalOptions.forEach { option ->
val shortName = option.shortName?.let { "-$it, " } ?: " "
println(" $shortName--${option.longName} [value]")
option.description?.let { desc ->
println(" $desc (can be used as flag or with value)")
}
}
}
if (flags.isNotEmpty()) {
println()
println("Flags:")
@@ -84,7 +102,8 @@ abstract class Subcommand(
println("Arguments:")
arguments.forEach { arg ->
val required = if (arg.required) " (required)" else " (optional)"
println(" ${arg.name}$required")
val typeInfo = getTypeInfo(arg.type)
println(" ${arg.name}$typeInfo$required")
arg.description?.let { desc ->
println(" $desc")
}
@@ -107,4 +126,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))
}
}