decline

repository·main·Indexed 20 days ago

https://github.com/bkirwi/decline

A composable command-line parser for Scala, inspired by optparse-applicative and built on top of the cats library. It provides a DSL via Opts and Command to define options, flags, positional arguments, and subcommands, with built-in support for environment variables and automatic help text generation.

Tokens
11.9K
Snippets
40
Records
46
Agent score
71%

What's inside decline

  1. Core features of decline

    main

    CLI Idioms

    decline supports standard Unix command-line patterns:

    • Flags: Boolean switches (e.g., --quiet).
    • Options: Key-value pairs (e.g., --target <string>).
    • Positional Arguments: Arguments identified by position.
    • Subcommands: Hierarchical command structures.
    • Mutually-exclusive options: Ensuring only one of a set of options is provided.
    • Custom validations: Logic to ensure input meets specific criteria.

    Design Principles

    • Full-featured: Supports complex CLI shapes including subcommands and validation.
    • Helpful: Automatically generates usage text and precise error messages.
    • Functional: Built on cats, providing an immutable and functional API that works in both functional and imperative codebases.
  2. Implement mutual exclusion using `orElse`

    main

    You can express mutual exclusion (e.g., requiring either a local file OR a remote URI, but not both) by using the orElse method on Opts. This is often cleaner than using ad-hoc validation on a large config object because it uses precise data modeling (like a sealed trait) to make illegal states unrepresentable. decline will automatically handle the error if the user provides both or neither.

    sealed trait InputConfig
    case class RemoteConfig(uri: URI, timeout: Duration) extends InputConfig
    case class LocalConfig(file: Path) extends InputConfig
    
    val remoteOpts = (uriOpt, timeoutOpt).mapN(RemoteConfig.apply)
    val localOpts = fileOpt.map(LocalConfig.apply)
    
    // Use orElse to ensure only one of these is provided
    val inputOpts = remoteOpts orElse localOpts
    
    case class Config(
        input: InputConfig,
        queries: Path,
    )
    
    val configOpts = (inputOpts, outputOpt).mapN(Config.apply)
  3. Understand why certain types lack Argument instances

    main

    decline does not provide Argument instances for some types to encourage more idiomatic CLI patterns:

    • Boolean: Instead of --verbose true, use Opts.flag("verbose", "...").orFalse to support the idiomatic --verbose flag.
    • java.io.File / java.net.URL: Use java.nio.file.Path and java.net.URI instead, as they are modern alternatives.
    • List[A]: To avoid complex comma-separated parsing issues, use Opts.options or Opts.arguments to allow users to repeat the flag (e.g., --exclude foo --exclude bar) instead of providing a single list (e.g., --exclude foo,bar).
  4. Combine multiple options

    main

    Use cats applicative syntax to combine multiple Opts into a single action.

    • mapN: Combines multiple options and passes their values into a function.
    • tupled: Combines multiple options into a single Opts that returns a tuple.
    • orElse: Used for mutual exclusivity or providing alternatives. The parser will choose the first alternative that matches the command-line arguments.
    import cats.syntax.all._
    
    // Combine options into a single effect
    val tailOptions = (linesOrDefault, fileList).mapN { (n, files) =>
      println(s"LOG: Printing the last $n lines from each file in $files!")
    }
    
    // Combine options into a tuple
    val tailOptionsTuple = (linesOrDefault, fileList).tupled
    
    // Mutual exclusivity: choose verbose, or if quiet is present, return -1, otherwise 0
    val verbosity = verbose orElse quiet.map { _ => -1 } withDefault 0
  5. Use the Effect style for direct execution

    main

    While the 'Config pattern' is great for testing, you can also use an 'Effect style' where you skip intermediate configuration objects and map Opts directly to your application's effectful functions (e.g., functions returning IO, Future, or String). This is more direct and concise but can be harder to test and validate.

    import cats.effect.IO
    
    def fetchRemote(uri: URI, timeout: Duration): IO[String] = ???
    def fetchLocal(file: Path): IO[String] = ???
    
    val remoteOpts = (uriOpt, timeoutOpt).mapN(fetchRemote)
    val localOpts = fileOpt.map(fetchLocal)
    val inputOpts = remoteOpts orElse localOpts
    
    def run(input: IO[String], output: Path): IO[Unit] = ???
    
    val configOpts = (inputOpts, outputOpt).mapN(run)
  6. Group related options into sub-configurations

    main

    To manage complexity in large CLIs, group related options into smaller case classes. This allows you to validate groups of options together and ensures that dependent options (like a timeout that only applies to a uri) are always provided together. This also improves the autogenerated Usage: help text.

    case class RemoteConfig(uri: URI, timeout: Duration)
    
    // Grouping uri and timeout into RemoteConfig
    val remoteOpts = (uriOpt, timeoutOpt).mapN(RemoteConfig.apply)
    
    case class Config(
        remote: Option[RemoteConfig],
        file: Option[Path],
        output: Path,
    )
    
    val configOpts = 
      (remoteOpts.orNone, fileOpt.orNone, outputOpt)
        .mapN(Config.apply)
        .validate("must provide either uri or file")(c => c.remote.isDefined ^ c.file.isDefined)
  7. How argument types work in decline

    main

    When you specify a type for an option (e.g., Opts.option[Path](...)), decline uses the com.monovore.decline.Argument type class to perform two tasks:

    1. Parsing: It provides a function to interpret the input string into the target type. If parsing fails, decline reports an error.
    2. Metavar Generation: It provides a default 'metavar' (the placeholder text shown in help output, like <path>) to help users understand the expected input format.

    decline provides built-in instances for common types like String, numbers, Path, and URI.

    import com.monovore.decline._
    import java.nio.file.Path
    
    val path = Opts.option[Path]("input", "Path to the input file.")
  8. Structure a complex CLI with the Config pattern

    main

    For large CLI applications, the recommended pattern is to build a parser that mirrors your application's configuration data structure. You define individual Opts for each parameter, combine them into smaller case classes using mapN, and finally compose these into a top-level Config object. This approach makes the CLI easy to test by allowing you to assert that specific command-line arguments parse into the expected configuration state.

    Example: Basic Config Pattern

    import com.monovore.decline._
    import cats.syntax.all._
    import java.net.URI
    import scala.concurrent.duration.Duration
    import java.nio.file.Path
    
    val uriOpt = Opts.option[URI]("input-uri", "Location of the remote file.")
    val timeoutOpt = Opts.option[Duration]("timeout", "Timeout for fetching the remote file.").withDefault(Duration.Inf)
    val fileOpt = Opts.option[Path]("input-file", "Local path to input file.")
    val outputOpt = Opts.argument[Path]("output-file")
    
    case class Config(
        uri: Option[URI],
        timeout: Duration,
        file: Option[Path],
        output: Path,
    ) 
    
    val configOpts: Opts[Config] =
      (uriOpt.orNone, timeoutOpt, fileOpt.orNone, outputOpt)
        .mapN(Config.apply)
        .validate("remote uri must be https")(_.uri.forall(_.getScheme == "https"))
        .validate("timeout option is only valid for remote files")(c => 
          c.timeout != Duration.Inf || c.uri.isDefined
        )
        .validate("must provide either uri or file")(c => c.uri.isDefined ^ c.file.isDefined)
    
    def runApp(config: Config) = ???
    configOpts.map(runApp)
  9. Define a custom Argument instance

    main

    If you have a custom type that needs specific parsing logic and a custom metavar, you can implement the com.monovore.decline.Argument type class. This is cleaner than using .mapValidated repeatedly in your command definitions.

    Implement the read method (returning Validated[Nel[String], T]) and the defaultMetavar method.

    import cats.data.Validated
    
    case class Config(key: String, value: String)
    
    implicit val configArgument: Argument[Config] = new Argument[Config] {
      def read(string: String) = {
        string.split(":", 2) match {
          case Array(key, value) => Validated.valid(Config(key, value))
          case _ => Validated.invalidNel(s"Invalid key:value pair: $string")
        }
      }
    
      def defaultMetavar = "key:value"
    }
    
    // Usage
    val configOpt = Opts.option[Config]("config", "Specify an additional config.")
  10. Create a command-line application with CommandApp

    main

    To build a CLI application, extend or instantiate CommandApp. You provide a name for the application, a header for the help text, and a main block which contains the logic for parsing options and executing the application. The main block typically uses Opts to define command-line arguments and then applies the parsed values to your application logic using functional combinators like mapN.

    import cats.syntax.all._
    import com.monovore.decline._
    
    object HelloWorld extends CommandApp(
      name = "hello-world",
      header = "Says hello!",
      main = {
        val userOpt =
          Opts.option[String]("target", help = "Person to greet.")
            .withDefault("world")
    
        val quietOpt = Opts.flag("quiet", help = "Whether to be quiet.").orFalse
    
        (userOpt, quietOpt).mapN { (user, quiet) => 
    
          if (quiet) println("...")
          else println(s"Hello $user!")
        }
      }
    )
  11. Integrate decline with Cats Effect

    main

    To use decline within a pure functional Cats Effect application, use the decline-effect module. This module provides CommandIOApp, which combines decline's rich CLI parsing capabilities with the effect management of Cats Effect's IOApp.

    libraryDependencies += "com.monovore" %% "decline-effect" % "@DECLINE_VERSION@"
  12. Use CommandApp for cross-platform Scala.js and JVM applications

    main

    To write a single command-line application that runs on both the JVM and JavaScript (Node.js), use the CommandApp abstraction. CommandApp handles the differences in how command-line arguments are accessed between the JVM and JavaScript environments, such as the lack of a standard main method in JS runtimes.

    When using Scala.js:

    • Ensure your Scala.js configuration is set to compile your code as an application.
    • The standard SBT run command does not forward arguments to Scala.js; you must build the JavaScript file and execute it manually using node.
    import com.monovore.decline._
    
    object MyApp extends CommandApp(
      name = "my-app",
      header = "This compiles to JavaScript!",
      main = {
        val loudOpt = Opts.flag("loud", "Do something noisy!").orFalse
        
        for (loud <- loudOpt) yield {
          if (loud) println("HELLO WORLD!")
          else println("hello world!")
        }
      }
    )