├── docs ├── .gitignore ├── pages │ ├── contrib-website.md │ ├── index.md │ ├── misc.md │ ├── setup.md │ ├── parse.md │ └── completion.md └── mkdocs.yml ├── tests ├── jvm │ └── src │ │ └── test │ │ ├── resources │ │ ├── args2 │ │ └── args1 │ │ └── scala │ │ └── caseapp │ │ ├── PlatformTests.scala │ │ └── DslExpandTests.scala ├── native │ └── src │ │ ├── test │ │ ├── resources │ │ │ ├── args2 │ │ │ └── args1 │ │ ├── scala-3 │ │ │ └── caseapp │ │ │ │ └── NativeUtil.scala │ │ ├── scala-2.12 │ │ │ └── caseapp │ │ │ │ └── NativeUtil.scala │ │ ├── scala-2.13 │ │ │ └── caseapp │ │ │ │ └── NativeUtil.scala │ │ └── scala │ │ │ └── caseapp │ │ │ └── DslExpandTests.scala │ │ └── main │ │ └── scala │ │ └── caseapp │ │ └── foo │ │ └── Foo.scala ├── shared │ └── src │ │ ├── main │ │ └── scala │ │ │ └── caseapp │ │ │ └── demo │ │ │ ├── ManualSubCommandOptions.scala │ │ │ ├── ManualCommand.scala │ │ │ └── ManualCommandNotAdtOptions.scala │ │ └── test │ │ └── scala │ │ └── caseapp │ │ ├── CaseAppDefinitions.scala │ │ ├── DslTests.scala │ │ ├── RuntimeCommandTests.scala │ │ ├── HelpDefinitions.scala │ │ ├── CompletionDefinitions.scala │ │ └── demo │ │ └── Demo.scala ├── jvm-native │ └── src │ │ └── test │ │ └── scala │ │ └── caseapp │ │ └── CompletionInstallTests.scala └── js │ └── src │ └── test │ └── scala │ └── caseapp │ └── os.scala ├── core ├── shared │ └── src │ │ └── main │ │ ├── scala-2 │ │ └── caseapp │ │ │ └── core │ │ │ ├── Scala3Helpers.scala │ │ │ ├── parser │ │ │ ├── Internal.scala │ │ │ ├── NilParser.scala │ │ │ ├── IgnoreUnrecognizedParser.scala │ │ │ ├── StopAtFirstUnrecognizedParser.scala │ │ │ ├── ParserWithNameFormatter.scala │ │ │ ├── EitherParser.scala │ │ │ ├── OptionParser.scala │ │ │ ├── MappedParser.scala │ │ │ ├── LowPriorityHListParserBuilder.scala │ │ │ ├── RecursiveConsParser.scala │ │ │ ├── ConsParser.scala │ │ │ ├── LowPriorityParserImplicits.scala │ │ │ ├── Parser.scala │ │ │ └── ParserOps.scala │ │ │ └── help │ │ │ ├── HelpCompanion.scala │ │ │ ├── WithFullHelpCompanion.scala │ │ │ └── WithHelpCompanion.scala │ │ ├── scala │ │ └── caseapp │ │ │ ├── core │ │ │ ├── argparser │ │ │ │ ├── Consumed.scala │ │ │ │ ├── package.scala │ │ │ │ ├── Last.scala │ │ │ │ ├── LastArgParser.scala │ │ │ │ ├── MapErrorArgParser.scala │ │ │ │ ├── AccumulatorArgParser.scala │ │ │ │ ├── FlagArgParser.scala │ │ │ │ ├── FlagAccumulatorArgParser.scala │ │ │ │ └── SimpleArgParser.scala │ │ │ ├── commandparser │ │ │ │ └── package.scala │ │ │ ├── parser │ │ │ │ ├── package.scala │ │ │ │ ├── Argument.scala │ │ │ │ ├── ParserCompanion.scala │ │ │ │ └── StandardArgument.scala │ │ │ ├── Counter.scala │ │ │ ├── help │ │ │ │ ├── RuntimeCommandHelp.scala │ │ │ │ ├── WithFullHelp.scala │ │ │ │ ├── CommandHelp.scala │ │ │ │ ├── WithHelp.scala │ │ │ │ ├── Table.scala │ │ │ │ ├── HelpFormat.scala │ │ │ │ ├── WordUtils.scala │ │ │ │ └── RuntimeCommandsHelp.scala │ │ │ ├── app │ │ │ │ ├── Command.scala │ │ │ │ └── ProfileFileUpdater.scala │ │ │ ├── complete │ │ │ │ ├── CompletionItem.scala │ │ │ │ ├── Fish.scala │ │ │ │ ├── HelpCompleter.scala │ │ │ │ ├── CompletionsUninstallOptions.scala │ │ │ │ ├── Bash.scala │ │ │ │ ├── CompletionsInstallOptions.scala │ │ │ │ ├── Zsh.scala │ │ │ │ └── Completer.scala │ │ │ ├── default │ │ │ │ └── Default.scala │ │ │ ├── util │ │ │ │ ├── CaseUtil.scala │ │ │ │ ├── Formatter.scala │ │ │ │ └── NameOps.scala │ │ │ ├── RemainingArgs.scala │ │ │ ├── package.scala │ │ │ ├── Arg.scala │ │ │ ├── Indexed.scala │ │ │ └── Error.scala │ │ │ └── package.scala │ │ └── scala-3 │ │ ├── caseapp │ │ └── core │ │ │ ├── parser │ │ │ ├── Internal.scala │ │ │ ├── NilParser.scala │ │ │ ├── OptionParser.scala │ │ │ ├── IgnoreUnrecognizedParser.scala │ │ │ ├── StopAtFirstUnrecognizedParser.scala │ │ │ ├── ParserWithNameFormatter.scala │ │ │ ├── EitherParser.scala │ │ │ ├── MappedParser.scala │ │ │ ├── ParserOps.scala │ │ │ ├── ConsParser.scala │ │ │ ├── RecursiveConsParser.scala │ │ │ └── Parser.scala │ │ │ ├── help │ │ │ ├── WithFullHelpCompanion.scala │ │ │ ├── WithHelpCompanion.scala │ │ │ └── HelpCompanion.scala │ │ │ └── Scala3Helpers.scala │ │ └── dataclass │ │ ├── since.scala │ │ └── data.scala ├── js │ └── src │ │ └── main │ │ └── scala │ │ └── caseapp │ │ └── core │ │ ├── argparser │ │ └── PlatformArgParsers.scala │ │ ├── help │ │ └── PlatformUtil.scala │ │ ├── app │ │ ├── nio │ │ │ ├── Paths.scala │ │ │ ├── File.scala │ │ │ ├── Path.scala │ │ │ ├── Files.scala │ │ │ └── FileOps.scala │ │ └── PlatformUtil.scala │ │ └── parser │ │ └── PlatformArgsExpander.scala ├── native │ └── src │ │ └── main │ │ └── scala │ │ └── caseapp │ │ └── core │ │ ├── argparser │ │ └── PlatformArgParsers.scala │ │ ├── help │ │ └── PlatformUtil.scala │ │ ├── app │ │ └── PlatformUtil.scala │ │ └── parser │ │ └── PlatformArgsExpander.scala ├── jvm │ └── src │ │ └── main │ │ └── scala │ │ └── caseapp │ │ └── core │ │ ├── help │ │ └── PlatformUtil.scala │ │ ├── app │ │ └── PlatformUtil.scala │ │ ├── parser │ │ └── PlatformArgsExpander.scala │ │ └── argparser │ │ └── PlatformArgParsers.scala └── jvm-native │ └── src │ └── main │ └── scala │ └── caseapp │ └── core │ └── app │ └── nio │ ├── package.scala │ └── FileOps.scala ├── .gitignore ├── .github ├── dependabot.yml ├── scripts │ └── setup-mkdocs.sh └── workflows │ └── ci.yml ├── annotations └── shared │ └── src │ └── main │ └── scala │ └── caseapp │ ├── annotation │ └── Tag.scala │ └── Annotations.scala ├── util └── shared │ └── src │ └── main │ ├── scala-3 │ └── caseapp │ │ └── util │ │ └── internal │ │ └── Unused.scala │ └── scala-2 │ └── caseapp │ └── util │ ├── AnnotationOption.scala │ ├── AnnotationList.scala │ ├── LowPriority.scala │ └── AnnotationListMacros.scala ├── .git-blame-ignore-revs ├── cats └── shared │ └── src │ ├── main │ └── scala │ │ └── caseapp │ │ └── catseffect │ │ ├── package.scala │ │ └── IOCaseApp.scala │ └── test │ └── scala │ └── caseapp │ └── catseffect │ ├── Definitions.scala │ └── CatsTests.scala ├── LICENSE ├── cats2 └── shared │ └── src │ └── test │ └── scala │ └── caseapp │ └── catseffect │ ├── Definitions.scala │ └── CatsTests.scala ├── README.md └── .scalafmt.conf /docs/.gitignore: -------------------------------------------------------------------------------- 1 | docs/ 2 | site/ 3 | .cache/ 4 | -------------------------------------------------------------------------------- /tests/jvm/src/test/resources/args2: -------------------------------------------------------------------------------- 1 | b 2 | -a 3 | --other -------------------------------------------------------------------------------- /tests/native/src/test/resources/args2: -------------------------------------------------------------------------------- 1 | b 2 | -a 3 | --other -------------------------------------------------------------------------------- /tests/jvm/src/test/resources/args1: -------------------------------------------------------------------------------- 1 | -- 2 | b 3 | -a 4 | --other -------------------------------------------------------------------------------- /tests/native/src/test/resources/args1: -------------------------------------------------------------------------------- 1 | -- 2 | b 3 | -a 4 | --other -------------------------------------------------------------------------------- /core/shared/src/main/scala-2/caseapp/core/Scala3Helpers.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core 2 | 3 | object Scala3Helpers 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out/ 2 | .bsp/ 3 | .idea 4 | .idea_modules 5 | *.swp 6 | *~ 7 | .bloop/ 8 | .metals/ 9 | .vscode/ 10 | -------------------------------------------------------------------------------- /core/js/src/main/scala/caseapp/core/argparser/PlatformArgParsers.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.argparser 2 | 3 | abstract class PlatformArgParsers 4 | -------------------------------------------------------------------------------- /core/js/src/main/scala/caseapp/core/help/PlatformUtil.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.help 2 | 3 | object PlatformUtil { 4 | val NL = "\n" 5 | } 6 | -------------------------------------------------------------------------------- /tests/native/src/test/scala-3/caseapp/NativeUtil.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | 3 | object NativeUtil { 4 | def scalaBinaryVersion = "3.3.6" 5 | } 6 | -------------------------------------------------------------------------------- /core/native/src/main/scala/caseapp/core/argparser/PlatformArgParsers.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.argparser 2 | 3 | abstract class PlatformArgParsers 4 | -------------------------------------------------------------------------------- /core/native/src/main/scala/caseapp/core/help/PlatformUtil.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.help 2 | 3 | object PlatformUtil { 4 | val NL = "\n" 5 | } 6 | -------------------------------------------------------------------------------- /tests/native/src/test/scala-2.12/caseapp/NativeUtil.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | 3 | object NativeUtil { 4 | def scalaBinaryVersion = "2.12" 5 | } 6 | -------------------------------------------------------------------------------- /tests/native/src/test/scala-2.13/caseapp/NativeUtil.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | 3 | object NativeUtil { 4 | def scalaBinaryVersion = "2.13" 5 | } 6 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/argparser/Consumed.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.argparser 2 | 3 | final case class Consumed(value: Boolean) extends AnyVal 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | -------------------------------------------------------------------------------- /core/js/src/main/scala/caseapp/core/app/nio/Paths.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.app.nio 2 | 3 | object Paths { 4 | def get(path: String): Path = 5 | Path(path) 6 | } 7 | -------------------------------------------------------------------------------- /core/js/src/main/scala/caseapp/core/app/nio/File.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.app.nio 2 | 3 | object File { 4 | def separator = Path.nodePath.sep.asInstanceOf[String] 5 | } 6 | -------------------------------------------------------------------------------- /core/jvm/src/main/scala/caseapp/core/help/PlatformUtil.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.help 2 | 3 | object PlatformUtil { 4 | val NL = System.getProperty("line.separator") 5 | } 6 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/commandparser/package.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core 2 | 3 | /** Command parsing-related things. 4 | */ 5 | package object commandparser 6 | -------------------------------------------------------------------------------- /core/js/src/main/scala/caseapp/core/parser/PlatformArgsExpander.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | object PlatformArgsExpander { 4 | def expand(args: List[String]): List[String] = args 5 | } 6 | -------------------------------------------------------------------------------- /annotations/shared/src/main/scala/caseapp/annotation/Tag.scala: -------------------------------------------------------------------------------- 1 | package caseapp.annotation 2 | 3 | import scala.annotation.StaticAnnotation 4 | 5 | final case class Tag(name: String) extends StaticAnnotation 6 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-2/caseapp/core/parser/Internal.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | private[parser] object Internal { 4 | type uncheckedVarianceScala2 = scala.annotation.unchecked.uncheckedVariance 5 | } 6 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-3/caseapp/core/parser/Internal.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | private[parser] object Internal { 4 | final class uncheckedVarianceScala2 extends scala.annotation.StaticAnnotation 5 | } 6 | -------------------------------------------------------------------------------- /util/shared/src/main/scala-3/caseapp/util/internal/Unused.scala: -------------------------------------------------------------------------------- 1 | package caseapp.util.internal 2 | 3 | /** Unused class, so that the util module isn't empty in Scala 3 */ 4 | private[internal] sealed abstract class Unused 5 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/parser/package.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core 2 | 3 | /** All-the-arguments parsing stuff. 4 | * 5 | * Mostly revolves around [[caseapp.core.parser.Parser]]. 6 | */ 7 | package object parser 8 | -------------------------------------------------------------------------------- /tests/native/src/main/scala/caseapp/foo/Foo.scala: -------------------------------------------------------------------------------- 1 | package caseapp.foo 2 | 3 | /** Triggers the creation of a dummy `tests/native/target/scala-2.11/classes` to make scala-native 4 | * happy 5 | */ 6 | sealed abstract class Foo 7 | -------------------------------------------------------------------------------- /core/jvm/src/main/scala/caseapp/core/app/PlatformUtil.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.app 2 | 3 | object PlatformUtil { 4 | def exit(code: Int): Nothing = 5 | sys.exit(code) 6 | def arguments(args: Array[String]): Array[String] = 7 | args 8 | } 9 | -------------------------------------------------------------------------------- /core/native/src/main/scala/caseapp/core/app/PlatformUtil.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.app 2 | 3 | object PlatformUtil { 4 | def exit(code: Int): Nothing = 5 | sys.exit(code) 6 | def arguments(args: Array[String]): Array[String] = 7 | args 8 | } 9 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/argparser/package.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core 2 | 3 | /** Things related to parsing a single argument. 4 | * 5 | * Mostly revolves around [[caseapp.core.argparser.ArgParser]]. 6 | */ 7 | package object argparser 8 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/Counter.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core 2 | 3 | /** Helper to count how many times a flag argument is specified. 4 | * 5 | * Should be used with [[Int]] and [[caseapp.@@]], like `Int @@ Counter`. 6 | */ 7 | sealed abstract class Counter 8 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/help/RuntimeCommandHelp.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.help 2 | 3 | import dataclass.data 4 | 5 | @data case class RuntimeCommandHelp[T]( 6 | names: List[List[String]], 7 | help: Help[T], 8 | group: String, 9 | hidden: Boolean 10 | ) 11 | -------------------------------------------------------------------------------- /.github/scripts/setup-mkdocs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | pip install \ 5 | mkdocs==1.6.0 \ 6 | mkdocs-material==9.5.21 \ 7 | mkdocs-git-committers-plugin-2==2.3.0 \ 8 | mkdocs-git-revision-date-localized-plugin==1.2.6 \ 9 | mkdocs-material==9.5.21 \ 10 | pymdown-extensions==10.8.1 11 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-3/dataclass/since.scala: -------------------------------------------------------------------------------- 1 | // from https://github.com/alexarchambault/data-class/blob/cb28e25100090785fc6ae790a6cc9f6f79bb2043/src/main/scala/dataclass/since.scala 2 | 3 | package dataclass 4 | 5 | import scala.annotation.StaticAnnotation 6 | 7 | class since(val version: String = "") extends StaticAnnotation 8 | -------------------------------------------------------------------------------- /tests/shared/src/main/scala/caseapp/demo/ManualSubCommandOptions.scala: -------------------------------------------------------------------------------- 1 | package caseapp.demo 2 | 3 | import caseapp.{ArgsName, ProgName} 4 | 5 | object ManualSubCommandOptions { 6 | 7 | @ProgName("c1") 8 | @ArgsName("c1-stuff") 9 | final case class Command1Opts(s: String) 10 | 11 | @ProgName("c2") 12 | final case class Command2Opts(b: Boolean) 13 | 14 | } 15 | -------------------------------------------------------------------------------- /core/jvm-native/src/main/scala/caseapp/core/app/nio/package.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.app 2 | 3 | package object nio { 4 | 5 | type Path = java.nio.file.Path 6 | 7 | object Paths { 8 | def get(path: String): Path = 9 | java.nio.file.Paths.get(path) 10 | } 11 | 12 | object File { 13 | def separator: String = 14 | java.io.File.separator 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Format everything (#340) 2 | 44b600f0e172b1a6bf8c58b804df079d639945fb 3 | 4 | # Scala Steward: Reformat with scalafmt 3.5.9 5 | 8195b904b48eb35487048f08ac9b4467c5453cf5 6 | 7 | # Scala Steward: Reformat with scalafmt 3.7.1 8 | 005a27dcde36c4f71bee0944d16c368a3ada2040 9 | 10 | # Scala Steward: Reformat with scalafmt 3.8.6 11 | 72852aa4b1f97ba73487607f4318ac0d4372671d 12 | -------------------------------------------------------------------------------- /tests/shared/src/main/scala/caseapp/demo/ManualCommand.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | package demo 3 | 4 | sealed abstract class ManualCommandOptions extends Product with Serializable 5 | 6 | @ProgName("c1") 7 | @ArgsName("c1-stuff") 8 | final case class Command1Opts(s: String) extends ManualCommandOptions 9 | 10 | @ProgName("c2") 11 | final case class Command2Opts(b: Boolean) extends ManualCommandOptions 12 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/app/Command.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.app 2 | 3 | import caseapp.core.parser.Parser 4 | import caseapp.core.help.Help 5 | 6 | abstract class Command[T](implicit parser: Parser[T], help: Help[T]) 7 | extends CaseApp()(parser, help) { 8 | def names: List[List[String]] = 9 | List(List(name)) 10 | def group: String = "" 11 | def hidden: Boolean = false 12 | } 13 | -------------------------------------------------------------------------------- /docs/pages/contrib-website.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | ## Building the website locally 4 | 5 | ### Watch mode 6 | 7 | Run 8 | ```text 9 | $ ./mill -i docs.mkdocsServe 10 | ``` 11 | 12 | Then open the URL printed in the console in your browser (it should be 13 | [`http://127.0.0.1:8000`](http://127.0.0.1:8000)) 14 | 15 | ### Once 16 | 17 | Build the website in the `docs/site` directory with 18 | ```text 19 | $ ./mill -i docs.mkdocsBuild 20 | ``` 21 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/argparser/Last.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.argparser 2 | 3 | /** Allows an argument to be specified multiple times. 4 | * 5 | * Discards previously specified values. 6 | * 7 | * @see 8 | * [[caseapp.core.argparser.LastArgParser]] 9 | * 10 | * @param value: 11 | * actual value of type [[T]] 12 | * @tparam T: 13 | * wrapped type 14 | */ 15 | final case class Last[T](value: T) // extends AnyVal // having issues since Scala Native 0.4.1 16 | -------------------------------------------------------------------------------- /tests/shared/src/test/scala/caseapp/CaseAppDefinitions.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | 3 | import caseapp.core.Error 4 | 5 | object CaseAppDefinitions { 6 | 7 | object HasHelp { 8 | final class Errored(val error: Error) extends Exception(error.message) 9 | final case class Options() 10 | object App extends CaseApp[Options] { 11 | override def hasHelp = false 12 | override def error(error: Error) = 13 | throw new Errored(error) 14 | def run(options: Options, args: RemainingArgs): Unit = 15 | ??? 16 | } 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-3/dataclass/data.scala: -------------------------------------------------------------------------------- 1 | // same as https://github.com/alexarchambault/data-class/blob/cb28e25100090785fc6ae790a6cc9f6f79bb2043/src/main/scala/dataclass/data.scala 2 | // but stripping the macros 3 | 4 | package dataclass 5 | 6 | import scala.annotation.{StaticAnnotation, compileTimeOnly} 7 | 8 | @compileTimeOnly("enable macro paradise to expand macro annotations") 9 | class data( 10 | apply: Boolean = true, 11 | publicConstructor: Boolean = true, 12 | optionSetters: Boolean = false, 13 | settersCallApply: Boolean = false 14 | ) extends StaticAnnotation 15 | -------------------------------------------------------------------------------- /docs/pages/index.md: -------------------------------------------------------------------------------- 1 | # case-app 2 | 3 | *case-app* is command-line argument parser for Scala that relies on case classes 4 | 5 | It offers to 6 | define options in simple case classes, supports sub-commands, offers 7 | support for completion, … 8 | 9 | It supports Scala on the JVM, 10 | [Scala.js](https://github.com/scala-js/scala-js), and 11 | [Scala Native](https://github.com/scala-native/scala-native). 12 | 13 | It's used by [coursier](https://github.com/coursier/coursier), 14 | [Scala CLI](https://github.com/VirtusLab/scala-cli), 15 | [Almond](https://github.com/almond-sh/almond), … 16 | -------------------------------------------------------------------------------- /cats/shared/src/main/scala/caseapp/catseffect/package.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | 3 | import caseapp.core.argparser.{AccumulatorArgParser, ArgParser} 4 | import cats.data.NonEmptyList 5 | 6 | package object catseffect { 7 | implicit def nonEmptyListArgParser[T]( 8 | implicit parser: ArgParser[T] 9 | ): AccumulatorArgParser[NonEmptyList[T]] = 10 | AccumulatorArgParser.from(parser.description + "*") { (prevOpt, idx, span, s) => 11 | parser(None, idx, span, s).map { t => 12 | // inefficient for big lists 13 | prevOpt.fold(NonEmptyList.one(t))(_ :+ t) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/shared/src/main/scala/caseapp/demo/ManualCommandNotAdtOptions.scala: -------------------------------------------------------------------------------- 1 | package caseapp.demo 2 | 3 | import caseapp.{ArgsName, ProgName} 4 | 5 | object ManualCommandNotAdtOptions { 6 | 7 | @ProgName("c1") 8 | @ArgsName("c1-stuff") 9 | final case class Command1Opts(s: String) 10 | 11 | @ProgName("c2") 12 | final case class Command2Opts(b: Boolean) 13 | 14 | @ProgName("c3") 15 | final case class Command3Opts(n: Int = 2) 16 | 17 | @ProgName("c4") 18 | final case class Command4Opts(someString: String = "default") 19 | 20 | @ProgName("c5") 21 | final case class Command5Opts(l: Long = 0) 22 | } 23 | -------------------------------------------------------------------------------- /core/jvm/src/main/scala/caseapp/core/parser/PlatformArgsExpander.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import java.nio.charset.StandardCharsets.UTF_8 4 | import java.nio.file._ 5 | 6 | object PlatformArgsExpander { 7 | 8 | def expand(args: List[String]): List[String] = 9 | args.flatMap { arg => 10 | if (arg.startsWith("@")) { 11 | val argPath = Paths.get(arg.substring(1)) 12 | val argText = new String(Files.readAllBytes(argPath), UTF_8) 13 | argText.split(System.lineSeparator).map(_.trim).filter(_.nonEmpty).toList 14 | } 15 | else 16 | List(arg) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /core/native/src/main/scala/caseapp/core/parser/PlatformArgsExpander.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import java.nio.charset.StandardCharsets.UTF_8 4 | import java.nio.file._ 5 | 6 | object PlatformArgsExpander { 7 | 8 | def expand(args: List[String]): List[String] = 9 | args.flatMap { arg => 10 | if (arg.startsWith("@")) { 11 | val argPath = Paths.get(arg.substring(1)) 12 | val argText = new String(Files.readAllBytes(argPath), UTF_8) 13 | argText.split(System.lineSeparator).map(_.trim).filter(_.nonEmpty).toList 14 | } 15 | else 16 | List(arg) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/help/WithFullHelp.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.help 2 | 3 | import caseapp.core.Error 4 | import caseapp.{ExtraName, Group, HelpMessage, Recurse} 5 | import caseapp.core.parser.Parser 6 | 7 | final case class WithFullHelp[+T]( 8 | @Recurse 9 | withHelp: WithHelp[T], 10 | @Group("Help") 11 | @HelpMessage("Print help message, including hidden options, and exit") 12 | @ExtraName("fullHelp") 13 | helpFull: Boolean = false 14 | ) { 15 | def map[U](f: T => U): WithFullHelp[U] = 16 | copy(withHelp = withHelp.map(f)) 17 | } 18 | 19 | object WithFullHelp extends WithFullHelpCompanion 20 | -------------------------------------------------------------------------------- /core/js/src/main/scala/caseapp/core/app/PlatformUtil.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.app 2 | 3 | import scala.scalajs.js 4 | import js.Dynamic.{global => g} 5 | 6 | object PlatformUtil { 7 | private lazy val process = g.require("process") 8 | def exit(code: Int): Nothing = { 9 | process.exit(code) 10 | sys.error(s"Attempt to exit with code $code failed") 11 | } 12 | def arguments(args: Array[String]): Array[String] = 13 | if (args.isEmpty) 14 | process.argv 15 | .asInstanceOf[js.Array[String]] 16 | .toArray 17 | .drop(2) // drop "node" and "/path/to/app.js" 18 | else 19 | args 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Alexandre Archambault 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | 15 | -------------------------------------------------------------------------------- /core/js/src/main/scala/caseapp/core/app/nio/Path.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.app.nio 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.Dynamic.{global => g} 5 | 6 | final case class Path(underlying: String) { 7 | import Path.nodePath 8 | def resolve(chunk: String): Path = 9 | Path(nodePath.join(underlying, chunk).asInstanceOf[String]) 10 | def getFileName: Path = 11 | Path(nodePath.basename(underlying).asInstanceOf[String]) 12 | def getParent: Path = 13 | Path(nodePath.join(underlying, "..").asInstanceOf[String]) 14 | override def toString: String = underlying 15 | } 16 | 17 | object Path { 18 | private[nio] lazy val nodePath = g.require("path") 19 | } 20 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/complete/CompletionItem.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.complete 2 | 3 | import dataclass.data 4 | 5 | @data case class CompletionItem( 6 | value: String, 7 | description: Option[String] = None, 8 | extraValues: Seq[String] = Nil 9 | ) { 10 | def values: Seq[String] = value +: extraValues 11 | 12 | def withPrefix(prefix: String): Option[CompletionItem] = 13 | if (prefix.isEmpty) Some(this) 14 | else { 15 | val updatedValues = values.filter(_.startsWith(prefix)) 16 | if (updatedValues.isEmpty) None 17 | else { 18 | val item = CompletionItem(values.head, description, values.tail) 19 | Some(item) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/default/Default.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.default 2 | 3 | import caseapp.{@@, Tag} 4 | import caseapp.core.Counter 5 | 6 | /** Default value for type `T` 7 | * 8 | * Allows to give fields of type `T` a default value, even if none was explicitly specified. 9 | */ 10 | final case class Default[T](value: T) extends AnyVal 11 | 12 | object Default { 13 | 14 | implicit def option[T]: Default[Option[T]] = 15 | Default(None) 16 | 17 | implicit def list[T]: Default[List[T]] = 18 | Default(Nil) 19 | 20 | implicit def vector[T]: Default[Vector[T]] = 21 | Default(Vector.empty) 22 | 23 | implicit val counter: Default[Int @@ Counter] = 24 | Default(Tag.of(0)) 25 | 26 | } 27 | -------------------------------------------------------------------------------- /cats/shared/src/test/scala/caseapp/catseffect/Definitions.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | package catseffect 3 | 4 | import cats.data.NonEmptyList 5 | 6 | object Definitions { 7 | 8 | final case class FewArgs( 9 | value: String = "default", 10 | numFoo: Int = -10 11 | ) 12 | 13 | final case class WithNonEmptyList(nel: NonEmptyList[String]) 14 | 15 | sealed trait Command 16 | 17 | case class First( 18 | @ExtraName("f") 19 | foo: String = "", 20 | bar: Int = 0 21 | ) extends Command 22 | 23 | case class Second( 24 | fooh: String = "", 25 | baz: Int = 0 26 | ) extends Command 27 | 28 | @HelpMessage("Third help message") 29 | case class Third( 30 | third: Int = 0 31 | ) extends Command 32 | 33 | } 34 | -------------------------------------------------------------------------------- /cats2/shared/src/test/scala/caseapp/catseffect/Definitions.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | package catseffect 3 | 4 | import cats.data.NonEmptyList 5 | 6 | object Definitions { 7 | 8 | final case class FewArgs( 9 | value: String = "default", 10 | numFoo: Int = -10 11 | ) 12 | 13 | final case class WithNonEmptyList(nel: NonEmptyList[String]) 14 | 15 | sealed trait Command 16 | 17 | case class First( 18 | @ExtraName("f") 19 | foo: String = "", 20 | bar: Int = 0 21 | ) extends Command 22 | 23 | case class Second( 24 | fooh: String = "", 25 | baz: Int = 0 26 | ) extends Command 27 | 28 | @HelpMessage("Third help message") 29 | case class Third( 30 | third: Int = 0 31 | ) extends Command 32 | 33 | } 34 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/util/CaseUtil.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.util 2 | 3 | object CaseUtil { 4 | 5 | def pascalCaseSplit(s: List[Char]): List[String] = 6 | if (s.isEmpty) 7 | Nil 8 | else if (!s.head.isUpper) { 9 | val (w, tail) = s.span(!_.isUpper) 10 | w.mkString :: pascalCaseSplit(tail) 11 | } 12 | else if (s.tail.headOption.forall(!_.isUpper)) { 13 | val (w, tail) = s.tail.span(!_.isUpper) 14 | (s.head :: w).mkString :: pascalCaseSplit(tail) 15 | } 16 | else { 17 | val (w, tail) = s.span(_.isUpper) 18 | if (tail.isEmpty) 19 | w.mkString :: pascalCaseSplit(tail) 20 | else 21 | w.init.mkString :: pascalCaseSplit(w.last :: tail) 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /core/js/src/main/scala/caseapp/core/app/nio/Files.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.app.nio 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.Dynamic.{global => g} 5 | 6 | object Files { 7 | 8 | def exists(path: Path): Boolean = 9 | nodeFs.existsSync(path.underlying).asInstanceOf[Boolean] 10 | def createDirectories(path: Path): Unit = 11 | nodeFs.mkdirSync(path.underlying, js.Dictionary("recursive" -> true)) 12 | def isRegularFile(path: Path): Boolean = 13 | exists(path) && nodeFs.statSync(path.underlying).isFile().asInstanceOf[Boolean] 14 | def deleteIfExists(path: Path): Boolean = { 15 | nodeFs.rmSync(path.underlying, js.Dictionary("recursive" -> true)) 16 | true 17 | } 18 | 19 | private lazy val nodeFs = g.require("fs") 20 | } 21 | -------------------------------------------------------------------------------- /util/shared/src/main/scala-2/caseapp/util/AnnotationOption.scala: -------------------------------------------------------------------------------- 1 | package caseapp.util 2 | 3 | import shapeless.Annotation 4 | 5 | sealed abstract class AnnotationOption[A, T] extends Serializable { 6 | def apply(): Option[A] 7 | } 8 | 9 | abstract class LowPriorityAnnotationOption { 10 | implicit def annotationNotFound[A, T]: AnnotationOption[A, T] = 11 | new AnnotationOption[A, T] { 12 | def apply() = None 13 | } 14 | } 15 | 16 | object AnnotationOption extends LowPriorityAnnotationOption { 17 | def apply[A, T](implicit annOpt: AnnotationOption[A, T]): AnnotationOption[A, T] = annOpt 18 | 19 | implicit def annotationFound[A, T](implicit 20 | annotation: Annotation[A, T] 21 | ): AnnotationOption[A, T] = 22 | new AnnotationOption[A, T] { 23 | def apply() = Some(annotation()) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /util/shared/src/main/scala-2/caseapp/util/AnnotationList.scala: -------------------------------------------------------------------------------- 1 | package caseapp.util 2 | 3 | import scala.language.experimental.macros 4 | import shapeless.{HList, DepFn0} 5 | 6 | sealed abstract class AnnotationList[A, T] extends DepFn0 with Serializable { 7 | type Out <: HList 8 | } 9 | 10 | object AnnotationList { 11 | def apply[A, T](implicit annotations: AnnotationList[A, T]): Aux[A, T, annotations.Out] = 12 | annotations 13 | 14 | type Aux[A, T, Out0 <: HList] = AnnotationList[A, T] { type Out = Out0 } 15 | 16 | def instance[A, T, Out0 <: HList](annotations: => Out0): Aux[A, T, Out0] = 17 | new AnnotationList[A, T] { 18 | type Out = Out0 19 | def apply() = annotations 20 | } 21 | 22 | implicit def materialize[A, T, Out <: HList]: Aux[A, T, Out] = 23 | macro AnnotationListMacros.materializeAnnotationList[A, T, Out] 24 | } 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # case-app 2 | 3 | *Type-level & seamless command-line argument parsing for Scala* 4 | 5 | [![Build status](https://github.com/alexarchambault/case-app/workflows/CI/badge.svg)](https://github.com/alexarchambault/case-app/actions?query=workflow%3ACI) 6 | [![Maven Central](https://img.shields.io/maven-central/v/com.github.alexarchambault/case-app_3.svg)](https://maven-badges.herokuapp.com/maven-central/com.github.alexarchambault/case-app_3) 7 | [![javadoc](https://javadoc.io/badge2/com.github.alexarchambault/case-app_3/javadoc.svg)](https://javadoc.io/doc/com.github.alexarchambault/case-app_3) 8 | 9 | See the [website](https://alexarchambault.github.io/case-app/) for more details. Website built with [![Built with Material for MkDocs](https://img.shields.io/badge/Material_for_MkDocs-526CFE?logo=MaterialForMkDocs&logoColor=white)](https://squidfunk.github.io/mkdocs-material/) 10 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-2/caseapp/core/parser/NilParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.Name 4 | import caseapp.core.{Arg, Error} 5 | import caseapp.core.util.Formatter 6 | import shapeless.HNil 7 | 8 | case object NilParser extends Parser[HNil] { 9 | 10 | type D = HNil 11 | 12 | def init: D = 13 | HNil 14 | 15 | def step( 16 | args: List[String], 17 | index: Int, 18 | d: HNil, 19 | formatter: Formatter[Name] 20 | ): Right[(Error, Arg, List[String]), None.type] = 21 | Right(None) 22 | 23 | def get(d: D, formatter: Formatter[Name]): Right[Error, HNil] = 24 | Right(HNil) 25 | 26 | def args: Nil.type = 27 | scala.Nil 28 | 29 | def ::[A](argument: Argument[A]): ConsParser[A, HNil, HNil] = 30 | ConsParser[A, HNil, HNil](argument, this) 31 | 32 | def withDefaultOrigin(origin: String): Parser.Aux[HNil, D] = 33 | this 34 | } 35 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-3/caseapp/core/parser/NilParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.Name 4 | import caseapp.core.{Arg, Error} 5 | import caseapp.core.util.Formatter 6 | 7 | case object NilParser extends Parser[EmptyTuple] { 8 | 9 | type D = EmptyTuple 10 | 11 | def init: D = EmptyTuple 12 | 13 | def step( 14 | args: List[String], 15 | index: Int, 16 | d: EmptyTuple, 17 | formatter: Formatter[Name] 18 | ): Right[(Error, Arg, List[String]), None.type] = 19 | Right(None) 20 | 21 | def get(d: D, formatter: Formatter[Name]): Right[Error, EmptyTuple] = 22 | Right(EmptyTuple) 23 | 24 | def args: Nil.type = 25 | scala.Nil 26 | 27 | def ::[A](argument: Argument[A]): ConsParser[A, EmptyTuple] = 28 | ConsParser[A, EmptyTuple](argument, this) 29 | 30 | def withDefaultOrigin(origin: String): Parser[EmptyTuple] = 31 | this 32 | } 33 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/RemainingArgs.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core 2 | 3 | import dataclass.data 4 | 5 | /** Arguments that don't correspond to options. 6 | * 7 | * @param remaining: 8 | * arguments before any `--` 9 | * @param unparsed: 10 | * arguments after a first `--`, if any 11 | */ 12 | @data case class RemainingArgs( 13 | indexedRemaining: Seq[Indexed[String]], 14 | indexedUnparsed: Seq[Indexed[String]] 15 | ) { 16 | 17 | lazy val remaining: Seq[String] = indexedRemaining.map(_.value) 18 | lazy val unparsed: Seq[String] = indexedUnparsed.map(_.value) 19 | 20 | /** Arguments both before and after a `--`. 21 | * 22 | * The first `--`, if any, is not included in this list. 23 | */ 24 | lazy val all: Seq[String] = 25 | remaining ++ unparsed 26 | 27 | lazy val indexed: Seq[Indexed[String]] = 28 | indexedRemaining ++ indexedUnparsed 29 | } 30 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/complete/Fish.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.complete 2 | 3 | object Fish { 4 | 5 | val shellName: String = 6 | "fish" 7 | val id: String = 8 | s"$shellName-v1" 9 | 10 | def script(progName: String): String = 11 | s"""complete $progName -a '($progName complete $id (math 1 + (count (__fish_print_cmd_args))) (__fish_print_cmd_args))' 12 | |""".stripMargin 13 | 14 | private def escape(s: String): String = 15 | s.replace("\t", " ").linesIterator.find(_ => true).getOrElse("") 16 | def print(items: Seq[CompletionItem]): String = { 17 | val newLine = System.lineSeparator() 18 | val b = new StringBuilder 19 | for (item <- items; value <- item.values) { 20 | b.append(escape(value)) 21 | for (desc <- item.description) { 22 | b.append("\t") 23 | b.append(escape(desc)) 24 | } 25 | b.append(newLine) 26 | } 27 | b.result() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/parser/Argument.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.argparser.ArgParser 4 | import caseapp.core.{Arg, Error} 5 | import caseapp.core.util.Formatter 6 | import caseapp.Name 7 | 8 | trait Argument[H] { 9 | def arg: Arg 10 | def withDefaultOrigin(origin: String): Argument[H] 11 | def init: Option[H] 12 | def step( 13 | args: List[String], 14 | index: Int, 15 | d: Option[H], 16 | nameFormatter: Formatter[Name] 17 | ): Either[(Error, List[String]), Option[(Option[H], List[String])]] 18 | def get(d: Option[H], nameFormatter: Formatter[Name]): Either[Error, H] 19 | } 20 | 21 | object Argument { 22 | def apply[H: ArgParser](arg: Arg): Argument[H] = 23 | StandardArgument[H](arg) 24 | def apply[H]( 25 | arg: Arg, 26 | argParser: ArgParser[H], 27 | default: () => Option[H] 28 | ): StandardArgument[H] = 29 | StandardArgument[H](arg, argParser, default) 30 | } 31 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/help/CommandHelp.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.help 2 | 3 | import caseapp.core.Arg 4 | import dataclass.data 5 | import caseapp.HelpMessage 6 | 7 | @data case class CommandHelp( 8 | args: Seq[Arg], 9 | argsNameOption: Option[String], 10 | helpMessage: Option[HelpMessage] 11 | ) { 12 | 13 | def usageMessage(progName: String, commandName: Seq[String]): String = 14 | s"Usage: $progName ${commandName.mkString(" ")} ${argsNameOption.map("<" + _ + ">").mkString}" 15 | 16 | def optionsMessage: String = 17 | Help.optionsMessage(args) 18 | 19 | def helpMessage(progName: String, commandName: Seq[String]): String = { 20 | val b = new StringBuilder 21 | b ++= s"Command: ${commandName.mkString(" ")}${Help.NL}" 22 | for (m <- helpMessage) 23 | b ++= m.message 24 | b ++= usageMessage(progName, commandName) 25 | b ++= Help.NL 26 | b ++= optionsMessage 27 | b ++= Help.NL 28 | b.result() 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "3.9.6" 2 | 3 | align.preset = more 4 | maxColumn = 100 5 | assumeStandardLibraryStripMargin = true 6 | indent.defnSite = 2 7 | indentOperator.topLevelOnly = false 8 | align.preset = more 9 | align.openParenCallSite = false 10 | newlines.source = keep 11 | newlines.beforeMultiline = keep 12 | newlines.afterCurlyLambdaParams = keep 13 | newlines.alwaysBeforeElseAfterCurlyIf = true 14 | 15 | runner.dialect = scala3 16 | 17 | rewrite.rules = [ 18 | RedundantBraces 19 | RedundantParens 20 | SortModifiers 21 | ] 22 | 23 | rewrite.redundantBraces { 24 | ifElseExpressions = true 25 | includeUnitMethods = false 26 | stringInterpolation = true 27 | } 28 | 29 | rewrite.sortModifiers.order = [ 30 | "private", "final", "override", "protected", 31 | "implicit", "sealed", "abstract", "lazy" 32 | ] 33 | project.excludeFilters = [ 34 | ".metals" 35 | "target" 36 | ] 37 | 38 | fileOverride { 39 | "glob:**/scala-2/**" { 40 | runner.dialect = scala213 41 | } 42 | } -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/package.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | 3 | /** Core types / classes of caseapp. 4 | * 5 | * Not that in most use cases of caseapp, simply importing things right under [[caseapp]], rather 6 | * things from [[caseapp.core]], should be enough. 7 | * 8 | * This package is itself split in several sub-packages: 9 | * - [[caseapp.core.argparser]]: things related to parsing a single argument value, 10 | * - [[caseapp.core.parser]]: things related to parsing a sequence of arguments, 11 | * - [[caseapp.core.commandparser]]: things related to parsing a sequence of arguments, handling 12 | * commands, 13 | * - [[caseapp.core.help]]: things related to help messages, 14 | * - [[caseapp.core.app]]: helpers to create caseapp-based applications, 15 | * - [[caseapp.core.default]]: helper to set / define a default value for a given type, 16 | * - [[caseapp.core.util]]: utilities, mostly for internal use. 17 | */ 18 | package object core 19 | -------------------------------------------------------------------------------- /docs/pages/misc.md: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | 3 | ## cats-effect 4 | 5 | ```scala mdoc:reset-object:invisible 6 | import caseapp._ 7 | ``` 8 | 9 | case-app has a module helping using it in cats-effect applications. 10 | 11 | Add a dependency to it like 12 | 13 | ```scala mdoc:passthrough 14 | println("```scala") 15 | println("//> using dep com.github.alexarchambault::case-app-cats:@VERSION@") 16 | println("```") 17 | ``` 18 | (for other build tools, see [setup](setup.md) and change `case-app` to `case-app-cats`) 19 | 20 | Then use it like 21 | ```scala mdoc:silent 22 | import caseapp.catseffect._ 23 | import cats.data.NonEmptyList 24 | import cats.effect._ 25 | 26 | case class ExampleOptions( 27 | foo: String = "", 28 | thing: NonEmptyList[String] 29 | ) 30 | 31 | object IOCaseExample extends IOCaseApp[ExampleOptions] { 32 | def run(options: ExampleOptions, arg: RemainingArgs): IO[ExitCode] = IO { 33 | // Core of the app 34 | // ... 35 | ExitCode.Success 36 | } 37 | } 38 | ``` 39 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/help/WithHelp.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.help 2 | 3 | import caseapp.{ExtraName, Group, Help, HelpMessage, Parser, Recurse} 4 | import caseapp.core.Error 5 | 6 | /** Helper to add `--usage` and `--help` options to an existing type `T`. 7 | * 8 | * @param usage: 9 | * whether usage was requested 10 | * @param help: 11 | * whether help was requested 12 | * @param baseOrError: 13 | * parsed `T` in case of success, or error message else 14 | * @tparam T: 15 | * type to which usage and help options are added 16 | */ 17 | final case class WithHelp[+T]( 18 | @Group("Help") 19 | @HelpMessage("Print usage and exit") 20 | usage: Boolean = false, 21 | @Group("Help") 22 | @HelpMessage("Print help message and exit") 23 | @ExtraName("h") 24 | help: Boolean = false, 25 | @Recurse 26 | baseOrError: Either[Error, T] 27 | ) { 28 | def map[U](f: T => U): WithHelp[U] = 29 | copy(baseOrError = baseOrError.map(f)) 30 | } 31 | 32 | object WithHelp extends WithHelpCompanion 33 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/argparser/LastArgParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.argparser 2 | 3 | import caseapp.core.Error 4 | import dataclass.data 5 | 6 | @data case class LastArgParser[T](parser: ArgParser[T]) extends ArgParser[Last[T]] { 7 | 8 | def apply( 9 | current: Option[Last[T]], 10 | index: Int, 11 | span: Int, 12 | value: String 13 | ): Either[Error, Last[T]] = 14 | parser(None, index, span, value).map(Last(_)) 15 | 16 | override def optional( 17 | current: Option[Last[T]], 18 | index: Int, 19 | span: Int, 20 | value: String 21 | ): (Consumed, Either[Error, Last[T]]) = { 22 | val (consumed, res) = parser.optional(None, index, span, value) 23 | val res0 = res.map(t => Last(t)) 24 | (consumed, res0) 25 | } 26 | 27 | override def apply(current: Option[Last[T]], index: Int): Either[Error, Last[T]] = 28 | parser(None, index).map(Last(_)) 29 | 30 | override def isFlag: Boolean = 31 | parser.isFlag 32 | 33 | def description: String = 34 | parser.description 35 | 36 | } 37 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/argparser/MapErrorArgParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.argparser 2 | 3 | import caseapp.core.Error 4 | 5 | final class MapErrorArgParser[T, U]( 6 | argParser: ArgParser[T], 7 | from: U => T, 8 | to: T => Either[Error, U] 9 | ) extends ArgParser[U] { 10 | 11 | def apply(current: Option[U], index: Int, span: Int, value: String): Either[Error, U] = 12 | argParser(current.map(from), index, span, value).flatMap(to) 13 | 14 | override def optional( 15 | current: Option[U], 16 | index: Int, 17 | span: Int, 18 | value: String 19 | ): (Consumed, Either[Error, U]) = { 20 | val (consumed, res) = argParser.optional(current.map(from), index, span, value) 21 | val res0 = res.flatMap(to) 22 | (consumed, res0) 23 | } 24 | 25 | override def apply(current: Option[U], index: Int): Either[Error, U] = 26 | argParser(current.map(from), index).flatMap(to) 27 | 28 | override def isFlag: Boolean = 29 | argParser.isFlag 30 | 31 | def description: String = 32 | argParser.description 33 | 34 | } 35 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/complete/HelpCompleter.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.complete 2 | 3 | import caseapp.core.help.Help 4 | import caseapp.core.{Arg, RemainingArgs} 5 | 6 | class HelpCompleter[T](help: Help[T]) extends Completer[T] { 7 | def optionName(prefix: String, state: Option[T], args: RemainingArgs): List[CompletionItem] = 8 | help 9 | .args 10 | .iterator 11 | .flatMap { arg => 12 | val names = arg.names 13 | .map(help.nameFormatter.format) 14 | .map(n => (if (n.length == 1) "-" else "--") + n) 15 | .filter(_.startsWith(prefix)) 16 | if (names.isEmpty) Iterator.empty 17 | else 18 | Iterator(CompletionItem(names.head, arg.helpMessage.map(_.message), names.tail)) 19 | } 20 | .toList 21 | def optionValue( 22 | arg: Arg, 23 | prefix: String, 24 | state: Option[T], 25 | args: RemainingArgs 26 | ): List[CompletionItem] = 27 | Nil 28 | def argument(prefix: String, state: Option[T], args: RemainingArgs): List[CompletionItem] = 29 | Nil 30 | } 31 | -------------------------------------------------------------------------------- /core/jvm/src/main/scala/caseapp/core/argparser/PlatformArgParsers.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.argparser 2 | 3 | import java.nio.file.{InvalidPathException, Path, Paths} 4 | import java.text.{ParseException, SimpleDateFormat} 5 | import java.util.{Calendar, GregorianCalendar} 6 | 7 | import caseapp.core.Error 8 | 9 | abstract class PlatformArgParsers { 10 | 11 | implicit val path: ArgParser[Path] = 12 | SimpleArgParser.from("path/to/file") { pathString => 13 | try Right(Paths.get(pathString)) 14 | catch { 15 | case e: InvalidPathException => 16 | Left(Error.MalformedValue("path", e.getMessage)) 17 | } 18 | } 19 | 20 | implicit val calendar: ArgParser[Calendar] = 21 | SimpleArgParser.from("yyyy-MM-dd") { s => 22 | val c = new GregorianCalendar 23 | 24 | try { 25 | c.setTime(PlatformArgParsers.fmt.parse(s)) 26 | Right(c) 27 | } 28 | catch { 29 | case e: ParseException => 30 | Left(Error.MalformedValue("date", Option(e.getMessage).getOrElse(""))) 31 | } 32 | } 33 | 34 | } 35 | 36 | object PlatformArgParsers { 37 | 38 | private val fmt = new SimpleDateFormat("yyyy-MM-dd") 39 | 40 | } 41 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-2/caseapp/core/parser/IgnoreUnrecognizedParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.{Arg, Error} 4 | import dataclass.data 5 | import caseapp.core.util.Formatter 6 | import caseapp.Name 7 | 8 | @data class IgnoreUnrecognizedParser[T, D0](underlying: Parser.Aux[T, D0]) extends Parser[T] { 9 | type D = D0 10 | def init: D = underlying.init 11 | def step( 12 | args: List[String], 13 | index: Int, 14 | d: D, 15 | nameFormatter: Formatter[Name] 16 | ): Either[(Error, Arg, List[String]), Option[(D, Arg, List[String])]] = 17 | underlying.step(args, index, d, nameFormatter) 18 | def get(d: D, nameFormatter: Formatter[Name]): Either[Error, T] = 19 | underlying.get(d, nameFormatter) 20 | def args: Seq[Arg] = 21 | underlying.args 22 | override def defaultStopAtFirstUnrecognized: Boolean = 23 | underlying.defaultStopAtFirstUnrecognized 24 | override def defaultIgnoreUnrecognized: Boolean = 25 | true 26 | override def defaultNameFormatter: Formatter[Name] = 27 | underlying.defaultNameFormatter 28 | 29 | def withDefaultOrigin(origin: String): Parser.Aux[T, D] = 30 | withUnderlying(underlying.withDefaultOrigin(origin)) 31 | } 32 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-2/caseapp/core/parser/StopAtFirstUnrecognizedParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.{Arg, Error} 4 | import dataclass.data 5 | import caseapp.core.util.Formatter 6 | import caseapp.Name 7 | 8 | @data class StopAtFirstUnrecognizedParser[T, D0](underlying: Parser.Aux[T, D0]) extends Parser[T] { 9 | type D = D0 10 | def init: D = underlying.init 11 | def step( 12 | args: List[String], 13 | index: Int, 14 | d: D, 15 | nameFormatter: Formatter[Name] 16 | ): Either[(Error, Arg, List[String]), Option[(D, Arg, List[String])]] = 17 | underlying.step(args, index, d, nameFormatter) 18 | def get(d: D, nameFormatter: Formatter[Name]): Either[Error, T] = 19 | underlying.get(d, nameFormatter) 20 | def args: Seq[Arg] = 21 | underlying.args 22 | override def defaultStopAtFirstUnrecognized: Boolean = 23 | true 24 | override def defaultIgnoreUnrecognized: Boolean = 25 | underlying.defaultIgnoreUnrecognized 26 | override def defaultNameFormatter: Formatter[Name] = 27 | underlying.defaultNameFormatter 28 | 29 | def withDefaultOrigin(origin: String): Parser.Aux[T, D] = 30 | withUnderlying(underlying.withDefaultOrigin(origin)) 31 | } 32 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/util/Formatter.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.util 2 | 3 | import caseapp.{Name, Recurse} 4 | 5 | abstract class Formatter[T] { 6 | def format(t: T): String 7 | } 8 | 9 | object Formatter { 10 | 11 | /** Formats option arguments to a given format. 12 | * 13 | * Default formatter will format option arguments as `foo-bar`. 14 | */ 15 | val DefaultNameFormatter: Formatter[Name] = Default 16 | 17 | case object Default extends Formatter[Name] { 18 | def format(name: Name): String = 19 | CaseUtil 20 | .pascalCaseSplit(name.name.toList) 21 | .map(_.toLowerCase) 22 | .mkString("-") 23 | } 24 | 25 | /** Adds the prefix for the Recurse annotation to the names formatted by the formatter. 26 | * 27 | * Adds the prefix as `prefix-foo-bar`. 28 | */ 29 | def addRecursePrefix(recurse: Recurse, formatter: Formatter[Name]): Formatter[Name] = 30 | if (recurse.prefix.isEmpty()) formatter 31 | else 32 | new Formatter[Name] { 33 | def format(t: Name): String = { 34 | val formattedPrefix = formatter.format(Name(recurse.prefix)) 35 | s"$formattedPrefix-${formatter.format(t)}" 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/complete/CompletionsUninstallOptions.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.complete 2 | 3 | import caseapp.{HelpMessage, Name} 4 | import caseapp.core.help.Help 5 | import caseapp.core.parser.Parser 6 | 7 | // from https://github.com/VirtusLab/scala-cli/blob/eced0b35c769eca58ae6f1b1a3be0f29a8700859/modules/cli/src/main/scala/scala/cli/commands/uninstallcompletions/SharedUninstallCompletionsOptions.scala 8 | // format: off 9 | final case class CompletionsUninstallOptions( 10 | @HelpMessage("Path to `*rc` file, defaults to `.bashrc` or `.zshrc` depending on shell (bash or zsh only)") 11 | rcFile: Option[String] = None, 12 | @HelpMessage("Custom banner in comment placed in rc file") 13 | banner: String = "{NAME} completions", 14 | @HelpMessage("Custom completions name") 15 | name: Option[String] = None, 16 | @HelpMessage("Completions output directory (defaults to $XDG_CONFIG_HOME/fish/completions on fish)") 17 | @Name("o") 18 | output: Option[String] = None, 19 | ) 20 | // format: on 21 | 22 | object CompletionsUninstallOptions { 23 | implicit lazy val parser: Parser[CompletionsUninstallOptions] = Parser.derive 24 | implicit lazy val help: Help[CompletionsUninstallOptions] = Help.derive 25 | } 26 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-2/caseapp/core/parser/ParserWithNameFormatter.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.{Arg, Error} 4 | import dataclass.data 5 | import caseapp.core.util.Formatter 6 | import caseapp.Name 7 | 8 | @data class ParserWithNameFormatter[T, D0](underlying: Parser.Aux[T, D0], f: Formatter[Name]) 9 | extends Parser[T] { 10 | type D = D0 11 | 12 | def init: D = underlying.init 13 | 14 | def step( 15 | args: List[String], 16 | index: Int, 17 | d: D, 18 | nameFormatter: Formatter[Name] 19 | ): Either[(Error, Arg, List[String]), Option[(D, Arg, List[String])]] = 20 | underlying.step(args, index, d, nameFormatter) 21 | 22 | def get(d: D, nameFormatter: Formatter[Name]): Either[Error, T] = 23 | underlying.get(d, nameFormatter) 24 | 25 | def args: Seq[Arg] = underlying.args 26 | 27 | override def defaultStopAtFirstUnrecognized: Boolean = 28 | underlying.defaultStopAtFirstUnrecognized 29 | 30 | override def defaultIgnoreUnrecognized: Boolean = 31 | underlying.defaultIgnoreUnrecognized 32 | 33 | override def defaultNameFormatter: Formatter[Name] = f 34 | 35 | def withDefaultOrigin(origin: String): Parser.Aux[T, D] = 36 | withUnderlying(underlying.withDefaultOrigin(origin)) 37 | } 38 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-2/caseapp/core/parser/EitherParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.{Arg, Error} 4 | import dataclass.data 5 | import caseapp.core.util.Formatter 6 | import caseapp.Name 7 | 8 | @data class EitherParser[T, D0](underlying: Parser.Aux[T, D0]) extends Parser[Either[Error, T]] { 9 | 10 | type D = D0 11 | 12 | def init = underlying.init 13 | 14 | def step( 15 | args: List[String], 16 | index: Int, 17 | d: D, 18 | nameFormatter: Formatter[Name] 19 | ): Either[(Error, Arg, List[String]), Option[(D, Arg, List[String])]] = 20 | underlying.step(args, index, d, nameFormatter) 21 | 22 | def get(d: D, nameFormatter: Formatter[Name]): Right[Error, Either[Error, T]] = 23 | Right(underlying.get(d, nameFormatter)) 24 | 25 | def args: Seq[Arg] = 26 | underlying.args 27 | 28 | override def defaultStopAtFirstUnrecognized: Boolean = 29 | underlying.defaultStopAtFirstUnrecognized 30 | 31 | override def defaultIgnoreUnrecognized: Boolean = 32 | underlying.defaultIgnoreUnrecognized 33 | 34 | override def defaultNameFormatter: Formatter[Name] = 35 | underlying.defaultNameFormatter 36 | 37 | def withDefaultOrigin(origin: String): Parser.Aux[Either[Error, T], D] = 38 | withUnderlying(underlying.withDefaultOrigin(origin)) 39 | } 40 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-2/caseapp/core/parser/OptionParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.{Arg, Error} 4 | import dataclass.data 5 | import caseapp.core.util.Formatter 6 | import caseapp.Name 7 | 8 | @data class OptionParser[T, D0](underlying: Parser.Aux[T, D0]) extends Parser[Option[T]] { 9 | 10 | type D = D0 11 | 12 | def init: D = 13 | underlying.init 14 | 15 | def step( 16 | args: List[String], 17 | index: Int, 18 | d: D, 19 | nameFormatter: Formatter[Name] 20 | ): Either[(Error, Arg, List[String]), Option[(D, Arg, List[String])]] = 21 | underlying.step(args, index, d, nameFormatter) 22 | 23 | def get(d: D, nameFormatter: Formatter[Name]): Right[Error, Option[T]] = 24 | Right(underlying.get(d, nameFormatter).toOption) 25 | 26 | def args: Seq[Arg] = 27 | underlying.args 28 | 29 | override def defaultStopAtFirstUnrecognized: Boolean = 30 | underlying.defaultStopAtFirstUnrecognized 31 | 32 | override def defaultIgnoreUnrecognized: Boolean = 33 | underlying.defaultIgnoreUnrecognized 34 | 35 | override def defaultNameFormatter: Formatter[Name] = 36 | underlying.defaultNameFormatter 37 | 38 | def withDefaultOrigin(origin: String): Parser.Aux[Option[T], D] = 39 | withUnderlying(underlying.withDefaultOrigin(origin)) 40 | } 41 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-2/caseapp/core/parser/MappedParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.{Arg, Error} 4 | import dataclass.data 5 | import caseapp.core.util.Formatter 6 | import caseapp.Name 7 | 8 | @data class MappedParser[T, D0, U](underlying: Parser.Aux[T, D0], f: T => U) extends Parser[U] { 9 | 10 | type D = D0 11 | 12 | def init: D = 13 | underlying.init 14 | 15 | def step( 16 | args: List[String], 17 | index: Int, 18 | d: D, 19 | nameFormatter: Formatter[Name] 20 | ): Either[(Error, Arg, List[String]), Option[(D, Arg, List[String])]] = 21 | underlying.step(args, index, d, nameFormatter) 22 | 23 | def get(d: D, nameFormatter: Formatter[Name]): Either[Error, U] = 24 | underlying 25 | .get(d, nameFormatter) 26 | .map(f) 27 | 28 | def args: Seq[Arg] = 29 | underlying.args 30 | 31 | override def defaultStopAtFirstUnrecognized: Boolean = 32 | underlying.defaultStopAtFirstUnrecognized 33 | 34 | override def defaultIgnoreUnrecognized: Boolean = 35 | underlying.defaultIgnoreUnrecognized 36 | 37 | override def defaultNameFormatter: Formatter[Name] = 38 | underlying.defaultNameFormatter 39 | 40 | def withDefaultOrigin(origin: String): Parser.Aux[U, D] = 41 | withUnderlying(underlying.withDefaultOrigin(origin)) 42 | } 43 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-3/caseapp/core/parser/OptionParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.Scala3Helpers._ 4 | import caseapp.core.{Arg, Error} 5 | import dataclass.data 6 | import caseapp.core.util.Formatter 7 | import caseapp.Name 8 | 9 | case class OptionParser[T](underlying: Parser[T]) extends Parser[Option[T]] { 10 | 11 | type D = underlying.D 12 | 13 | def init: D = 14 | underlying.init 15 | 16 | def step( 17 | args: List[String], 18 | index: Int, 19 | d: D, 20 | nameFormatter: Formatter[Name] 21 | ): Either[(Error, Arg, List[String]), Option[(D, Arg, List[String])]] = 22 | underlying.step(args, index, d, nameFormatter) 23 | 24 | def get(d: D, nameFormatter: Formatter[Name]): Right[Error, Option[T]] = 25 | Right(underlying.get(d, nameFormatter).toOption) 26 | 27 | def args: Seq[Arg] = 28 | underlying.args 29 | 30 | override def defaultStopAtFirstUnrecognized: Boolean = 31 | underlying.defaultStopAtFirstUnrecognized 32 | 33 | override def defaultIgnoreUnrecognized: Boolean = 34 | underlying.defaultIgnoreUnrecognized 35 | 36 | override def defaultNameFormatter: Formatter[Name] = 37 | underlying.defaultNameFormatter 38 | 39 | def withDefaultOrigin(origin: String): Parser[Option[T]] = 40 | this.withUnderlying(underlying.withDefaultOrigin(origin)) 41 | } 42 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/complete/Bash.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.complete 2 | 3 | object Bash { 4 | 5 | val shellName: String = 6 | "bash" 7 | val id: String = 8 | s"$shellName-v1" 9 | 10 | def script(progName: String): String = { 11 | val ifs = "\\n" 12 | s"""_${progName}_completions() { 13 | | local IFS=$$'$ifs' 14 | | eval "$$($progName complete $id "$$(( $$COMP_CWORD + 1 ))" "$${COMP_WORDS[@]}")" 15 | |} 16 | | 17 | |complete -F _${progName}_completions $progName 18 | |""".stripMargin 19 | } 20 | 21 | private def escape(s: String): String = 22 | s.replace("\"", "\\\"").replace("`", "\\`").linesIterator.toStream.headOption.getOrElse("") 23 | def print(items: Seq[CompletionItem]): String = { 24 | val newLine = System.lineSeparator() 25 | val b = new StringBuilder 26 | val singleValue = items.iterator.flatMap(_.values).drop(1).isEmpty 27 | for (item <- items; value <- item.values) { 28 | b.append("\"") 29 | b.append(escape(value)) 30 | for (desc <- item.description if !singleValue) { 31 | b.append(" -- ") 32 | b.append(escape(desc)) 33 | } 34 | b.append("\"") 35 | b.append(newLine) 36 | } 37 | if (b.isEmpty) """COMPREPLY=($(compgen -f "${COMP_WORDS[$COMP_CWORD]}"))""" 38 | else "COMPREPLY=(" + b.result() + ")" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/Arg.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core 2 | 3 | import caseapp.core.Scala3Helpers._ 4 | import caseapp.{Group, HelpMessage, Name, Tag, ValueDescription} 5 | import dataclass._ 6 | 7 | /** Infos about an argument / option an application can accept. 8 | * 9 | * @param name: 10 | * main name of the argument 11 | * @param extraNames: 12 | * extra names 13 | * @param valueDescription: 14 | * description of its value (optional) 15 | * @param helpMessage: 16 | * help message for this argument (optional) 17 | * @param noHelp: 18 | * if true, this argument should not appear in help messages 19 | * @param isFlag: 20 | * if true, passing an actual value to this argument is optional 21 | */ 22 | @data case class Arg( 23 | name: Name, 24 | extraNames: Seq[Name] = Nil, 25 | valueDescription: Option[ValueDescription] = None, 26 | helpMessage: Option[HelpMessage] = None, 27 | noHelp: Boolean = false, 28 | isFlag: Boolean = false, 29 | @since 30 | group: Option[Group] = None, 31 | @since 32 | origin: Option[String] = None, 33 | @since 34 | tags: Seq[Tag] = Nil 35 | ) { 36 | def withDefaultOrigin(defaultOrigin: String): Arg = 37 | if (origin.isEmpty) this.withOrigin(Some(defaultOrigin)) 38 | else this 39 | lazy val names = name +: extraNames 40 | } 41 | 42 | object Arg { 43 | def apply(name: String): Arg = 44 | Arg(Name(name)) 45 | } 46 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/complete/CompletionsInstallOptions.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.complete 2 | 3 | import caseapp.{HelpMessage, Name} 4 | import caseapp.core.help.Help 5 | import caseapp.core.parser.Parser 6 | 7 | // from https://github.com/VirtusLab/scala-cli/blob/eced0b35c769eca58ae6f1b1a3be0f29a8700859/modules/cli/src/main/scala/scala/cli/commands/installcompletions/InstallCompletionsOptions.scala 8 | // format: off 9 | final case class CompletionsInstallOptions( 10 | @HelpMessage("Print completions to stdout") 11 | env: Boolean = false, 12 | @HelpMessage("Custom completions name") 13 | name: Option[String] = None, 14 | @HelpMessage("Name of the shell, either zsh, fish or bash") 15 | @Name("shell") 16 | format: Option[String] = None, 17 | @HelpMessage("Completions output directory (defaults to $XDG_CONFIG_HOME/fish/completions on fish)") 18 | @Name("o") 19 | output: Option[String] = None, 20 | @HelpMessage("Custom banner in comment placed in rc file (bash or zsh only)") 21 | banner: String = "{NAME} completions", 22 | @HelpMessage("Path to `*rc` file, defaults to `.bashrc` or `.zshrc` depending on shell (bash or zsh only)") 23 | rcFile: Option[String] = None 24 | ) 25 | // format: on 26 | 27 | object CompletionsInstallOptions { 28 | implicit lazy val parser: Parser[CompletionsInstallOptions] = Parser.derive 29 | implicit lazy val help: Help[CompletionsInstallOptions] = Help.derive 30 | } 31 | -------------------------------------------------------------------------------- /util/shared/src/main/scala-2/caseapp/util/LowPriority.scala: -------------------------------------------------------------------------------- 1 | package caseapp.util 2 | 3 | import scala.language.experimental.macros 4 | import scala.reflect.macros.whitebox 5 | 6 | /** Like [[shapeless.LowPriority]], but fine with the "aux pattern" (by "stripping refinements", 7 | * internally) 8 | */ 9 | sealed abstract class LowPriority extends Serializable 10 | 11 | object LowPriority { 12 | 13 | implicit def materialize: LowPriority = macro LowPriorityMacros.mkLowPriority 14 | 15 | } 16 | 17 | class LowPriorityMacros(val c: whitebox.Context) extends shapeless.OpenImplicitMacros 18 | with shapeless.LowPriorityTypes { 19 | import c.universe._ 20 | 21 | def strictTpe = typeOf[shapeless.Strict[_]].typeConstructor 22 | 23 | def stripRefinements(tpe: Type): Option[Type] = 24 | tpe match { 25 | case RefinedType(parents, _) => Some(parents.head) 26 | case _ => None 27 | } 28 | 29 | def mkLowPriority: Tree = 30 | secondOpenImplicitTpe match { 31 | case Some(tpe) => 32 | c.inferImplicitValue( 33 | appliedType( 34 | strictTpe, 35 | appliedType(lowPriorityForTpe, stripRefinements(tpe.dealias).getOrElse(tpe)) 36 | ), 37 | silent = false 38 | ) 39 | 40 | q"null: _root_.caseapp.util.LowPriority" 41 | 42 | case None => 43 | c.abort(c.enclosingPosition, "Can't get looked for implicit type") 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /core/jvm-native/src/main/scala/caseapp/core/app/nio/FileOps.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.app.nio 2 | 3 | import java.nio.charset.StandardCharsets 4 | import java.nio.file.{FileAlreadyExistsException, Files, Path, Paths, StandardOpenOption} 5 | 6 | object FileOps { 7 | 8 | def readFile(path: Path): String = 9 | new String(Files.readAllBytes(path), StandardCharsets.UTF_8) 10 | def writeFile(path: Path, content: String): Unit = 11 | Files.write(path, content.getBytes(StandardCharsets.UTF_8)) 12 | def appendToFile(path: Path, content: String): Unit = 13 | Files.write(path, content.getBytes(StandardCharsets.UTF_8), StandardOpenOption.APPEND) 14 | def readEnv(varName: String): Option[String] = 15 | Option(System.getenv(varName)) 16 | def homeDir: Path = 17 | Paths.get(sys.props("user.home")) 18 | 19 | def createDirectories(path: Path): Unit = 20 | try Files.createDirectories(path) 21 | catch { 22 | // Ignored, see https://bugs.openjdk.java.net/browse/JDK-8130464 23 | case _: FileAlreadyExistsException if Files.isDirectory(path) => 24 | } 25 | 26 | // simple aliases, to avoid explicit imports of Files, 27 | // which might point to _root_.java.nio.file.Files or _root_.caseapp.core.app.nio.Files 28 | def exists(path: Path): Boolean = 29 | Files.exists(path) 30 | def isRegularFile(path: Path): Boolean = 31 | Files.isRegularFile(path) 32 | def deleteIfExists(path: Path): Boolean = 33 | Files.deleteIfExists(path) 34 | } 35 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/parser/ParserCompanion.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.{Arg, Error} 4 | 5 | abstract class ParserCompanion { 6 | 7 | sealed abstract class Step extends Product with Serializable { 8 | def index: Int 9 | def consumed: Int 10 | } 11 | object Step { 12 | sealed abstract class SingleArg extends Step { 13 | final def consumed: Int = 1 14 | } 15 | final case class DoubleDash(index: Int) extends SingleArg 16 | final case class IgnoredUnrecognized(index: Int) extends SingleArg 17 | final case class FirstUnrecognized(index: Int, isOption: Boolean) extends SingleArg 18 | final case class Unrecognized(index: Int, error: Error.UnrecognizedArgument) extends SingleArg 19 | final case class StandardArgument(index: Int) extends SingleArg 20 | final case class MatchedOption(index: Int, consumed: Int, arg: Arg) extends Step 21 | final case class ErroredOption(index: Int, consumed: Int, arg: Arg, error: Error) extends Step 22 | } 23 | 24 | def consumed(initial: List[String], updated: List[String]): Int = 25 | initial match { 26 | case _ :: tail if tail eq updated => 1 27 | case _ :: _ :: tail if tail eq updated => 2 28 | case _ => 29 | initial.length - updated.length // kind of meh, might make parsing O(args.length^2) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /core/js/src/main/scala/caseapp/core/app/nio/FileOps.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.app.nio 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.Dynamic.{global => g} 5 | 6 | object FileOps { 7 | 8 | private lazy val nodeFs = g.require("fs") 9 | private lazy val nodeOs = g.require("os") 10 | private lazy val nodeProcess = g.require("process") 11 | 12 | def readFile(path: Path): String = 13 | nodeFs.readFileSync(path.underlying, js.Dictionary("encoding" -> "utf8")).asInstanceOf[String] 14 | def writeFile(path: Path, content: String): Unit = 15 | nodeFs.writeFileSync(path.underlying, content) 16 | def appendToFile(path: Path, content: String): Unit = 17 | nodeFs.writeFileSync(path.underlying, content, js.Dictionary("mode" -> "a")) 18 | def readEnv(varName: String): Option[String] = 19 | nodeProcess.env.asInstanceOf[js.Dictionary[String]].get(varName) 20 | def homeDir: Path = 21 | Paths.get(nodeOs.homedir().asInstanceOf[String]) 22 | 23 | def createDirectories(path: Path): Unit = 24 | nodeFs.mkdirSync(path.underlying, js.Dictionary("recursive" -> true)) 25 | 26 | // simple aliases, to avoid explicit imports of Files, 27 | // which might point to _root_.java.nio.file.Files or _root_.caseapp.core.app.nio.Files 28 | def exists(path: Path): Boolean = 29 | Files.exists(path) 30 | def isRegularFile(path: Path): Boolean = 31 | Files.isRegularFile(path) 32 | def deleteIfExists(path: Path): Boolean = 33 | Files.deleteIfExists(path) 34 | } 35 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/util/NameOps.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.util 2 | 3 | import scala.language.implicitConversions 4 | 5 | import caseapp.Name 6 | 7 | class NameOps(val name: Name) extends AnyVal { 8 | 9 | private def isShort: Boolean = 10 | name.name.length == 1 11 | 12 | private def optionEq(nameFormatter: Formatter[Name]): String = 13 | option(nameFormatter) + "=" 14 | 15 | def option(nameFormatter: Formatter[Name]): String = 16 | if (name.name.startsWith("-")) nameFormatter.format(name) 17 | else if (isShort) s"-${name.name}" 18 | else s"--${nameFormatter.format(name)}" 19 | 20 | def apply( 21 | args: List[String], 22 | isFlag: Boolean, 23 | formatter: Formatter[Name] 24 | ): Option[List[String]] = 25 | args match { 26 | case Nil => None 27 | case h :: t => 28 | if (h == option(formatter)) 29 | Some(t) 30 | else if (!isFlag && h.startsWith(optionEq(formatter))) 31 | Some(h.drop(optionEq(formatter).length) :: t) 32 | else 33 | None 34 | } 35 | 36 | def apply(arg: String, formatter: Formatter[Name]): Either[Unit, Option[String]] = 37 | if (arg == option(formatter)) 38 | Right(None) 39 | else if (arg.startsWith(optionEq(formatter))) 40 | Right(Some(arg.drop(optionEq(formatter).length))) 41 | else 42 | Left(()) 43 | 44 | } 45 | 46 | object NameOps { 47 | implicit def toNameOps(name: Name): NameOps = 48 | new NameOps(name) 49 | } 50 | -------------------------------------------------------------------------------- /tests/jvm/src/test/scala/caseapp/PlatformTests.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | 3 | import java.util.{Calendar, GregorianCalendar} 4 | import utest._ 5 | import java.nio.file._ 6 | import caseapp.core.Error 7 | import caseapp.core.help.WithHelp 8 | 9 | object PlatformTests extends TestSuite { 10 | 11 | final case class WithCalendar( 12 | date: Calendar 13 | ) 14 | 15 | implicit lazy val withCalendarParser0: Parser[WithCalendar] = Parser.derive 16 | 17 | val withCalendarParser: Seq[String] => Either[ 18 | caseapp.core.Error, 19 | (Either[Error, WithCalendar], Boolean, Boolean, Seq[String]) 20 | ] = 21 | CaseApp.parseWithHelp[WithCalendar] _ 22 | 23 | final case class WithPath( 24 | path: Path 25 | ) 26 | 27 | // unused, but we check that this derives a Parser for WithPath 28 | private def checkDerivation(args: Seq[String]) = 29 | CaseApp.parseWithHelp[WithPath](args) 30 | 31 | val tests = TestSuite { 32 | test("parse a date") { 33 | val res = Parser[WithCalendar].parse(Seq("--date", "2014-10-23")) 34 | val expectedRes = Right(( 35 | WithCalendar(date = new GregorianCalendar(2014, 9, 23)), 36 | Nil 37 | )) 38 | assert(res == expectedRes) 39 | } 40 | 41 | test("parse a path") { 42 | val res = Parser[WithPath].parse(Seq("--path", "/path/to/file.ext")) 43 | val expectedRes = Right(( 44 | WithPath(path = Paths.get("/path/to/file.ext")), 45 | Nil 46 | )) 47 | assert(res == expectedRes) 48 | } 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /tests/jvm/src/test/scala/caseapp/DslExpandTests.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | 3 | import java.nio.file.Paths 4 | 5 | import caseapp.core.Indexed 6 | import caseapp.core.parser.PlatformArgsExpander 7 | import caseapp.demo._ 8 | import utest._ 9 | 10 | object DslExpandTests extends TestSuite { 11 | 12 | import Definitions._ 13 | 14 | val tests = TestSuite { 15 | 16 | test("handle expanded extra user arguments 1") { 17 | val argfile = Paths.get(DslExpandTests.getClass.getResource("/args1").toURI) 18 | val parser: Parser[NoArgs] = Parser.derive 19 | val res = parser.detailedParse(PlatformArgsExpander.expand(List(s"@$argfile"))) 20 | val expectedRes = Right(( 21 | NoArgs(), 22 | RemainingArgs(Seq(), Seq(Indexed(1, 1, "b"), Indexed(2, 1, "-a"), Indexed(3, 1, "--other"))) 23 | )) 24 | assert(res == expectedRes) 25 | } 26 | 27 | test("handle expanded extra user arguments 2") { 28 | val argfile = Paths.get(DslExpandTests.getClass.getResource("/args2").toURI) 29 | val parser: Parser[NoArgs] = Parser.derive 30 | val res = parser.detailedParse(PlatformArgsExpander.expand(List("--", s"@$argfile"))) 31 | val expectedRes = Right(( 32 | NoArgs(), 33 | RemainingArgs( 34 | Seq(), 35 | Seq( 36 | Indexed(1, 1, "b"), 37 | Indexed(2, 1, "-a"), 38 | Indexed(3, 1, "--other") 39 | ) 40 | ) 41 | )) 42 | assert(res == expectedRes) 43 | } 44 | 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/help/Table.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.help 2 | 3 | import caseapp.core.util.fansi 4 | import dataclass.data 5 | 6 | @data case class Table(lines: IndexedSeq[Seq[fansi.Str]]) { 7 | 8 | def widths: Seq[Int] = 9 | if (lines.isEmpty) Nil 10 | else 11 | lines.head.indices.map { i => 12 | lines.iterator.map(_(i).length).max 13 | } 14 | 15 | def render( 16 | colSeparator: String, 17 | linePrefix: String, 18 | lineSeparator: String, 19 | defaultWidths: IndexedSeq[Int] 20 | ): String = { 21 | val b = new StringBuilder 22 | render(b, colSeparator, linePrefix, lineSeparator, defaultWidths) 23 | b.result() 24 | } 25 | 26 | def render( 27 | b: StringBuilder, 28 | colSeparator: String, 29 | linePrefix: String, 30 | lineSeparator: String, 31 | defaultWidths: IndexedSeq[Int] 32 | ): Unit = 33 | for ((line, lineIdx) <- lines.zipWithIndex) { 34 | b.append(linePrefix) 35 | val trailingEmptyCount = line.reverseIterator.takeWhile(_.length == 0).length 36 | for ((cell, colIdx) <- line.iterator.zipWithIndex) { 37 | val colDefaultWidth = defaultWidths(colIdx) 38 | b.append(cell.render) 39 | if (colIdx < line.length - 1 - trailingEmptyCount) { 40 | if (cell.length < colDefaultWidth) 41 | b.appendAll((0 until (colDefaultWidth - cell.length)).iterator.map(_ => ' ')) 42 | b.append(colSeparator) 43 | } 44 | } 45 | if (lineIdx < lines.length - 1) 46 | b.append(lineSeparator) 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-3/caseapp/core/parser/IgnoreUnrecognizedParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.Scala3Helpers._ 4 | import caseapp.core.{Arg, Error} 5 | import dataclass.data 6 | import caseapp.core.util.Formatter 7 | import caseapp.Name 8 | 9 | case class IgnoreUnrecognizedParser[T](underlying: Parser[T]) extends Parser[T] { 10 | import IgnoreUnrecognizedParser._ 11 | type D = underlying.D 12 | def init: D = underlying.init 13 | def step( 14 | args: List[String], 15 | index: Int, 16 | d: D, 17 | nameFormatter: Formatter[Name] 18 | ): Either[(Error, Arg, List[String]), Option[(D, Arg, List[String])]] = 19 | underlying.step(args, index, d, nameFormatter) 20 | def get(d: D, nameFormatter: Formatter[Name]): Either[Error, T] = 21 | underlying.get(d, nameFormatter) 22 | def args: Seq[Arg] = 23 | underlying.args 24 | override def defaultStopAtFirstUnrecognized: Boolean = 25 | underlying.defaultStopAtFirstUnrecognized 26 | override def defaultIgnoreUnrecognized: Boolean = 27 | true 28 | override def defaultNameFormatter: Formatter[Name] = 29 | underlying.defaultNameFormatter 30 | 31 | def withDefaultOrigin(origin: String): Parser[T] = 32 | this.withUnderlying(underlying.withDefaultOrigin(origin)) 33 | } 34 | 35 | object IgnoreUnrecognizedParser { 36 | 37 | implicit class IgnoreUnrecognizedParserWithOps[T]( 38 | private val parser: IgnoreUnrecognizedParser[T] 39 | ) extends AnyVal { 40 | def withUnderlying(underlying: Parser[T]): IgnoreUnrecognizedParser[T] = 41 | parser.copy(underlying = underlying) 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-3/caseapp/core/parser/StopAtFirstUnrecognizedParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.{Arg, Error} 4 | import dataclass.data 5 | import caseapp.core.util.Formatter 6 | import caseapp.Name 7 | 8 | case class StopAtFirstUnrecognizedParser[T](underlying: Parser[T]) 9 | extends Parser[T] { 10 | import StopAtFirstUnrecognizedParser._ 11 | type D = underlying.D 12 | def init: D = underlying.init 13 | def step( 14 | args: List[String], 15 | index: Int, 16 | d: D, 17 | nameFormatter: Formatter[Name] 18 | ): Either[(Error, Arg, List[String]), Option[(D, Arg, List[String])]] = 19 | underlying.step(args, index, d, nameFormatter) 20 | def get(d: D, nameFormatter: Formatter[Name]): Either[Error, T] = 21 | underlying.get(d, nameFormatter) 22 | def args: Seq[Arg] = 23 | underlying.args 24 | override def defaultStopAtFirstUnrecognized: Boolean = 25 | true 26 | override def defaultIgnoreUnrecognized: Boolean = 27 | underlying.defaultIgnoreUnrecognized 28 | override def defaultNameFormatter: Formatter[Name] = 29 | underlying.defaultNameFormatter 30 | 31 | def withDefaultOrigin(origin: String): Parser[T] = 32 | this.withUnderlying(underlying.withDefaultOrigin(origin)) 33 | } 34 | 35 | object StopAtFirstUnrecognizedParser { 36 | 37 | implicit class StopAtFirstUnrecognizedParserWithOps[T]( 38 | private val parser: StopAtFirstUnrecognizedParser[T] 39 | ) extends AnyVal { 40 | def withUnderlying(underlying: Parser[T]): StopAtFirstUnrecognizedParser[T] = 41 | parser.copy(underlying = underlying) 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /tests/native/src/test/scala/caseapp/DslExpandTests.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | 3 | import java.nio.file.Paths 4 | 5 | import caseapp.core.Indexed 6 | import caseapp.core.parser.PlatformArgsExpander 7 | import utest._ 8 | 9 | object DslExpandTests extends TestSuite { 10 | 11 | import Definitions._ 12 | 13 | def sbv = NativeUtil.scalaBinaryVersion 14 | 15 | val tests = TestSuite { 16 | 17 | test("handle expanded extra user arguments 1") { 18 | val parser: Parser[NoArgs] = Parser.derive 19 | val res = parser.detailedParse( 20 | PlatformArgsExpander.expand(List(s"@./tests/native/target/scala-$sbv/test-classes/args1")) 21 | ) 22 | val expectedRes = Right(( 23 | NoArgs(), 24 | RemainingArgs( 25 | Seq(), 26 | Seq( 27 | Indexed(1, 1, "b"), 28 | Indexed(2, 1, "-a"), 29 | Indexed(3, 1, "--other") 30 | ) 31 | ) 32 | )) 33 | assert(res == expectedRes) 34 | } 35 | 36 | test("handle expanded extra user arguments 2") { 37 | val parser: Parser[NoArgs] = Parser.derive 38 | val res = parser.detailedParse(PlatformArgsExpander.expand(List( 39 | "--", 40 | s"@./tests/native/target/scala-$sbv/test-classes/args2" 41 | ))) 42 | val expectedRes = Right(( 43 | NoArgs(), 44 | RemainingArgs( 45 | Seq(), 46 | Seq( 47 | Indexed(1, 1, "b"), 48 | Indexed(2, 1, "-a"), 49 | Indexed(3, 1, "--other") 50 | ) 51 | ) 52 | )) 53 | assert(res == expectedRes) 54 | } 55 | 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-3/caseapp/core/parser/ParserWithNameFormatter.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.{Arg, Error} 4 | import dataclass.data 5 | import caseapp.core.util.Formatter 6 | import caseapp.Name 7 | 8 | case class ParserWithNameFormatter[T](underlying: Parser[T], f: Formatter[Name]) 9 | extends Parser[T] { 10 | import ParserWithNameFormatter._ 11 | type D = underlying.D 12 | 13 | def init: D = underlying.init 14 | 15 | def step( 16 | args: List[String], 17 | index: Int, 18 | d: D, 19 | nameFormatter: Formatter[Name] 20 | ): Either[(Error, Arg, List[String]), Option[(D, Arg, List[String])]] = 21 | underlying.step(args, index, d, nameFormatter) 22 | 23 | def get(d: D, nameFormatter: Formatter[Name]): Either[Error, T] = 24 | underlying.get(d, nameFormatter) 25 | 26 | def args: Seq[Arg] = underlying.args 27 | 28 | override def defaultStopAtFirstUnrecognized: Boolean = 29 | underlying.defaultStopAtFirstUnrecognized 30 | 31 | override def defaultIgnoreUnrecognized: Boolean = 32 | underlying.defaultIgnoreUnrecognized 33 | 34 | override def defaultNameFormatter: Formatter[Name] = f 35 | 36 | def withDefaultOrigin(origin: String): Parser[T] = 37 | this.withUnderlying(underlying.withDefaultOrigin(origin)) 38 | } 39 | 40 | object ParserWithNameFormatter { 41 | 42 | implicit class ParserWithNameFormatterWithOps[T](private val parser: ParserWithNameFormatter[ 43 | T 44 | ]) extends AnyVal { 45 | def withUnderlying(underlying: Parser[T]): ParserWithNameFormatter[T] = 46 | parser.copy(underlying = underlying) 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/argparser/AccumulatorArgParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.argparser 2 | 3 | import caseapp.core.Error 4 | import dataclass.data 5 | 6 | @data case class AccumulatorArgParser[T]( 7 | description: String, 8 | parse: (Option[T], Int, Int, String) => Either[Error, T] 9 | ) extends ArgParser[T] { 10 | 11 | def apply(current: Option[T], index: Int, span: Int, value: String): Either[Error, T] = 12 | parse(current, index, span, value) 13 | 14 | } 15 | 16 | object AccumulatorArgParser { 17 | 18 | def from[T]( 19 | description: String 20 | )( 21 | parse: (Option[T], Int, Int, String) => Either[Error, T] 22 | ): AccumulatorArgParser[T] = 23 | AccumulatorArgParser(description, parse) 24 | 25 | // FIXME (former comment, deprecated?) may not be fine with sequences/options of flags 26 | 27 | def list[T](implicit parser: ArgParser[T]): AccumulatorArgParser[List[T]] = 28 | from(parser.description + "*") { (prevOpt, idx, span, s) => 29 | parser(None, idx, span, s).map { t => 30 | // inefficient for big lists 31 | prevOpt.getOrElse(Nil) :+ t 32 | } 33 | } 34 | 35 | def vector[T](implicit parser: ArgParser[T]): AccumulatorArgParser[Vector[T]] = 36 | from(parser.description + "*") { (prevOpt, idx, span, s) => 37 | parser(None, idx, span, s).map { t => 38 | prevOpt.getOrElse(Vector.empty) :+ t 39 | } 40 | } 41 | 42 | def option[T](implicit parser: ArgParser[T]): AccumulatorArgParser[Option[T]] = 43 | from(parser.description + "?") { (prevOpt, idx, span, s) => 44 | parser(prevOpt.flatten, idx, span, s).map(Some(_)) 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-3/caseapp/core/parser/EitherParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.Scala3Helpers._ 4 | import caseapp.core.{Arg, Error} 5 | import dataclass.data 6 | import caseapp.core.util.Formatter 7 | import caseapp.Name 8 | 9 | case class EitherParser[T](underlying: Parser[T]) 10 | extends Parser[Either[Error, T]] { 11 | 12 | import EitherParser._ 13 | 14 | type D = underlying.D 15 | 16 | def init = underlying.init 17 | 18 | def step( 19 | args: List[String], 20 | index: Int, 21 | d: D, 22 | nameFormatter: Formatter[Name] 23 | ): Either[(Error, Arg, List[String]), Option[(D, Arg, List[String])]] = 24 | underlying.step(args, index, d, nameFormatter) 25 | 26 | def get(d: D, nameFormatter: Formatter[Name]): Right[Error, Either[Error, T]] = 27 | Right(underlying.get(d, nameFormatter)) 28 | 29 | def args: Seq[Arg] = 30 | underlying.args 31 | 32 | override def defaultStopAtFirstUnrecognized: Boolean = 33 | underlying.defaultStopAtFirstUnrecognized 34 | 35 | override def defaultIgnoreUnrecognized: Boolean = 36 | underlying.defaultIgnoreUnrecognized 37 | 38 | override def defaultNameFormatter: Formatter[Name] = 39 | underlying.defaultNameFormatter 40 | 41 | def withDefaultOrigin(origin: String): Parser[Either[Error, T]] = 42 | this.withUnderlying(underlying.withDefaultOrigin(origin)) 43 | } 44 | 45 | object EitherParser { 46 | 47 | implicit class EitherParserWithOps[T](private val parser: EitherParser[T]) 48 | extends AnyVal { 49 | def withUnderlying(underlying: Parser[T]): EitherParser[T] = 50 | parser.copy(underlying = underlying) 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/Indexed.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core 2 | 3 | import caseapp.core.argparser.{ArgParser, Consumed} 4 | 5 | final case class Indexed[+T]( 6 | index: Int, 7 | length: Int, 8 | value: T 9 | ) 10 | 11 | object Indexed { 12 | 13 | def apply[T](value: T): Indexed[T] = 14 | Indexed(-1, 0, value) 15 | 16 | def list[T](seq: List[T], startIdx: Int): List[Indexed[T]] = 17 | seq.zipWithIndex.map { 18 | case (elem, idx) => 19 | Indexed(startIdx + idx, 1, elem) 20 | } 21 | 22 | def seq[T](seq: Seq[T], startIdx: Int): Seq[Indexed[T]] = 23 | seq.zipWithIndex.map { 24 | case (elem, idx) => 25 | Indexed(startIdx + idx, 1, elem) 26 | } 27 | 28 | implicit def argParser[T: ArgParser]: ArgParser[Indexed[T]] = 29 | new ArgParser[Indexed[T]] { 30 | private val underlying = ArgParser[T] 31 | def apply(current: Option[Indexed[T]], index: Int, span: Int, value: String) = 32 | underlying(current.map(_.value), index, span, value) 33 | .map(t => Indexed(index, span, t)) 34 | override def apply(current: Option[Indexed[T]], index: Int) = 35 | underlying(current.map(_.value), index) 36 | .map(t => Indexed(index, 1, t)) 37 | override def optional(current: Option[Indexed[T]], index: Int, span: Int, value: String) = { 38 | val (consumed, res) = underlying.optional(current.map(_.value), index, span, value) 39 | val len = if (consumed.value) span else 1 40 | (consumed, res.map(t => Indexed(index, len, t))) 41 | } 42 | override def isFlag = underlying.isFlag 43 | def description = underlying.description 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-3/caseapp/core/parser/MappedParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.Scala3Helpers._ 4 | import caseapp.core.{Arg, Error} 5 | import dataclass.data 6 | import caseapp.core.util.Formatter 7 | import caseapp.Name 8 | 9 | case class MappedParser[T, U](underlying: Parser[T], f: T => U) 10 | extends Parser[U] { 11 | 12 | import MappedParser._ 13 | 14 | type D = underlying.D 15 | 16 | def init: D = 17 | underlying.init 18 | 19 | def step( 20 | args: List[String], 21 | index: Int, 22 | d: D, 23 | nameFormatter: Formatter[Name] 24 | ): Either[(Error, Arg, List[String]), Option[(D, Arg, List[String])]] = 25 | underlying.step(args, index, d, nameFormatter) 26 | 27 | def get(d: D, nameFormatter: Formatter[Name]): Either[Error, U] = 28 | underlying 29 | .get(d, nameFormatter) 30 | .map(f) 31 | 32 | def args: Seq[Arg] = 33 | underlying.args 34 | 35 | override def defaultStopAtFirstUnrecognized: Boolean = 36 | underlying.defaultStopAtFirstUnrecognized 37 | 38 | override def defaultIgnoreUnrecognized: Boolean = 39 | underlying.defaultIgnoreUnrecognized 40 | 41 | override def defaultNameFormatter: Formatter[Name] = 42 | underlying.defaultNameFormatter 43 | 44 | def withDefaultOrigin(origin: String): Parser[U] = 45 | this.withUnderlying(underlying.withDefaultOrigin(origin)) 46 | } 47 | 48 | object MappedParser { 49 | 50 | implicit class MappedParserWithOps[T, U](private val parser: MappedParser[T, U]) 51 | extends AnyVal { 52 | def withUnderlying(underlying: Parser[T]): MappedParser[T, U] = 53 | parser.copy(underlying = underlying) 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/package.scala: -------------------------------------------------------------------------------- 1 | /** caseapp root package 2 | * 3 | * Simply importing things directly under this package should make it possible not to have to 4 | * import things from [[caseapp.core]]. 5 | */ 6 | package object caseapp { 7 | 8 | type ExtraName = Name 9 | val ExtraName = Name 10 | 11 | type Parser[T] = core.parser.Parser[T] 12 | val Parser = core.parser.Parser 13 | 14 | type Help[T] = core.help.Help[T] 15 | val Help = core.help.Help 16 | 17 | type CaseApp[T] = core.app.CaseApp[T] 18 | val CaseApp = core.app.CaseApp 19 | 20 | type Command[T] = core.app.Command[T] 21 | type CommandsEntryPoint = core.app.CommandsEntryPoint 22 | 23 | type RemainingArgs = core.RemainingArgs 24 | val RemainingArgs = core.RemainingArgs 25 | 26 | type Last[T] = core.argparser.Last[T] 27 | val Last = core.argparser.Last 28 | 29 | // Running into weird errors with this one when using Tag.of, so let's use newtype below instead 30 | // type @@[+T, Tag] = shapeless.tag.@@[T, Tag] 31 | // object Tag { 32 | // def of[Tag] = shapeless.tag[Tag] 33 | // def unwrap[T, Tag](t: T @@ Tag): T = t.asInstanceOf[T] 34 | // } 35 | 36 | // Custom tag implementation, see above for more details 37 | final case class @@[T, Tag](value: T) extends AnyVal 38 | type Tag = caseapp.annotation.Tag 39 | object Tag { 40 | def apply(name: String): Tag = 41 | caseapp.annotation.Tag(name) 42 | def unapply(tag: Tag): Option[String] = 43 | Some(tag.name) 44 | 45 | final class TagBuilder[Tag] { 46 | def apply[T](t: T): T @@ Tag = @@(t) 47 | } 48 | 49 | def of[Tag]: TagBuilder[Tag] = new TagBuilder[Tag] 50 | def unwrap[T, Tag](t: T @@ Tag): T = t.value 51 | } 52 | 53 | type Counter = core.Counter 54 | 55 | } 56 | -------------------------------------------------------------------------------- /tests/shared/src/test/scala/caseapp/DslTests.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | 3 | import utest._ 4 | 5 | object DslTests extends TestSuite { 6 | 7 | final case class Result(foo: Int, bar: String = "ab", value: Double) 8 | 9 | val tests: Tests = Tests { 10 | 11 | test("simple") { 12 | 13 | val dslParser = Parser.nil 14 | .add[Int]("foo") 15 | .add[String]("bar", default = Some("ab")) 16 | .add[Double]("value") 17 | .as[Result] 18 | 19 | val tupledParser = Parser.nil 20 | .add[Int]("foo") 21 | .add[String]("bar", default = Some("ab")) 22 | .add[Double]("value") 23 | .tupled 24 | 25 | val derivedParser = Parser[Result] 26 | 27 | test { 28 | val args = Seq("--foo", "2", "--bar", "bzz", "--value", "2.0") 29 | val dslRes = dslParser.parse(args) 30 | val derivedRes = derivedParser.parse(args) 31 | val expectedRes = Right((Result(2, "bzz", 2.0), Nil)) 32 | assert(dslRes == expectedRes) 33 | assert(derivedRes == expectedRes) 34 | 35 | val expectedTupledRes = Right(((2, "bzz", 2.0), Nil)) 36 | val tupledRes = tupledParser.parse(args) 37 | assert(tupledRes == expectedTupledRes) 38 | } 39 | 40 | test { 41 | val args = Seq("--foo", "2", "--value", "2.0") 42 | val dslRes = dslParser.parse(args) 43 | val derivedRes = derivedParser.parse(args) 44 | val expectedRes = Right((Result(2, "ab", 2.0), Nil)) 45 | assert(dslRes == expectedRes) 46 | assert(derivedRes == expectedRes) 47 | 48 | val expectedTupledRes = Right(((2, "ab", 2.0), Nil)) 49 | val tupledRes = tupledParser.parse(args) 50 | assert(tupledRes == expectedTupledRes) 51 | } 52 | } 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/argparser/FlagArgParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.argparser 2 | 3 | import caseapp.core.Error 4 | import dataclass.data 5 | 6 | @data case class FlagArgParser[T]( 7 | description: String, 8 | parse: (Option[String], Int, Int) => Either[Error, T] 9 | ) extends ArgParser[T] { 10 | 11 | def apply(current: Option[T], index: Int, span: Int, value: String): Either[Error, T] = 12 | parse(Some(value), index, span) 13 | 14 | override def optional( 15 | current: Option[T], 16 | index: Int, 17 | span: Int, 18 | value: String 19 | ): (Consumed, Either[Error, T]) = 20 | (Consumed(false), parse(None, index, span)) 21 | 22 | override def apply(current: Option[T], index: Int): Either[Error, T] = 23 | parse(None, index, 1) 24 | 25 | override def isFlag: Boolean = 26 | true 27 | 28 | } 29 | 30 | object FlagArgParser { 31 | 32 | def from[T](description: String)(parse: Option[String] => Either[Error, T]): FlagArgParser[T] = 33 | FlagArgParser(description, (valueOpt, _, _) => parse(valueOpt)) 34 | 35 | private val trues = Set("true", "1") 36 | private val falses = Set("false", "0") 37 | 38 | val unit: FlagArgParser[Unit] = 39 | from("flag") { 40 | case None => 41 | Right(()) 42 | case Some(s) => 43 | if (trues(s)) 44 | Right(()) 45 | else if (falses(s)) 46 | Left(Error.CannotBeDisabled) 47 | else 48 | Left(Error.UnrecognizedFlagValue(s)) 49 | } 50 | 51 | val boolean: FlagArgParser[Boolean] = 52 | from("bool") { 53 | case None => 54 | Right(true) 55 | case Some(s) => 56 | if (trues(s)) 57 | Right(true) 58 | else if (falses(s)) 59 | Right(false) 60 | else 61 | Left(Error.UnrecognizedFlagValue(s)) 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /tests/shared/src/test/scala/caseapp/RuntimeCommandTests.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | 3 | import caseapp.demo._ 4 | import caseapp.Definitions._ 5 | import caseapp.core.commandparser.RuntimeCommandParser 6 | import utest._ 7 | 8 | object RuntimeCommandTests extends TestSuite { 9 | 10 | val tests = Tests { 11 | test("not adt") { 12 | 13 | val commands = Map( 14 | List("c1") -> ManualCommandNotAdtStuff.Command1, 15 | List("c2") -> ManualCommandNotAdtStuff.Command2, 16 | List("c3") -> ManualCommandNotAdtStuff.Command3StopAtUnreco, 17 | List("c4") -> ManualCommandNotAdtStuff.Command4NameFormatter, 18 | List("c5") -> ManualCommandNotAdtStuff.Command5IgnoreUnrecognized 19 | ) 20 | 21 | test { 22 | val res = RuntimeCommandParser.parse(commands, List("c1", "-s", "aa")) 23 | val expectedRes = Some((List("c1"), ManualCommandNotAdtStuff.Command1, List("-s", "aa"))) 24 | assert(res == expectedRes) 25 | } 26 | 27 | test { 28 | val res = RuntimeCommandParser.parse(commands, List("c2", "-b")) 29 | val expectedRes = Some((List("c2"), ManualCommandNotAdtStuff.Command2, List("-b"))) 30 | assert(res == expectedRes) 31 | } 32 | } 33 | 34 | test("sub commands") { 35 | val commands = Map( 36 | List("foo") -> ManualSubCommandStuff.Command1, 37 | List("foo", "list") -> ManualSubCommandStuff.Command2 38 | ) 39 | test { 40 | val res = RuntimeCommandParser.parse(commands, List("foo", "-s", "aa")) 41 | val expectedRes = Some((List("foo"), ManualSubCommandStuff.Command1, List("-s", "aa"))) 42 | assert(res == expectedRes) 43 | } 44 | 45 | test { 46 | val res = RuntimeCommandParser.parse(commands, List("foo", "list", "-b")) 47 | val expectedRes = Some((List("foo", "list"), ManualSubCommandStuff.Command2, List("-b"))) 48 | assert(res == expectedRes) 49 | } 50 | } 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-2/caseapp/core/help/HelpCompanion.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.help 2 | 3 | import caseapp.{AppName, AppVersion, ArgsName, ProgName} 4 | import caseapp.core.parser.Parser 5 | import caseapp.core.util.CaseUtil 6 | import caseapp.util.AnnotationOption 7 | import caseapp.HelpMessage 8 | import shapeless.Typeable 9 | 10 | abstract class HelpCompanion { 11 | 12 | // FIXME Not sure Typeable is fine on Scala JS, should be replaced by something else 13 | 14 | def derive[T](implicit 15 | parser: Parser[T], 16 | typeable: Typeable[T], 17 | appName: AnnotationOption[AppName, T], 18 | appVersion: AnnotationOption[AppVersion, T], 19 | progName: AnnotationOption[ProgName, T], 20 | argsName: AnnotationOption[ArgsName, T], 21 | helpMessage: AnnotationOption[HelpMessage, T] 22 | ): Help[T] = 23 | help[T]( 24 | parser, 25 | typeable, 26 | appName, 27 | appVersion, 28 | progName, 29 | argsName, 30 | helpMessage 31 | ) 32 | 33 | /** Implicitly derives a `Help[T]` for `T` */ 34 | implicit def help[T](implicit 35 | parser: Parser[T], 36 | typeable: Typeable[T], 37 | appName: AnnotationOption[AppName, T], 38 | appVersion: AnnotationOption[AppVersion, T], 39 | progName: AnnotationOption[ProgName, T], 40 | argsName: AnnotationOption[ArgsName, T], 41 | helpMessage: AnnotationOption[HelpMessage, T] 42 | ): Help[T] = { 43 | 44 | val appName0 = appName() match { 45 | case None => 46 | if (typeable.describe == "Options") typeable.describe 47 | else typeable.describe.stripSuffix("Options") 48 | case Some(name) => 49 | name.appName 50 | } 51 | 52 | Help( 53 | parser.args, 54 | appName0, 55 | appVersion().fold("")(_.appVersion), 56 | progName().fold(CaseUtil.pascalCaseSplit(appName0.toList).map(_.toLowerCase).mkString("-"))( 57 | _.progName 58 | ), 59 | argsName().map(_.argsName), 60 | Help.DefaultOptionsDesc, 61 | parser.defaultNameFormatter, 62 | helpMessage() 63 | ) 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-2/caseapp/core/parser/LowPriorityHListParserBuilder.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp._ 4 | import caseapp.core.Arg 5 | import caseapp.core.argparser.ArgParser 6 | import caseapp.core.parser.HListParserBuilder.{Aux, instance} 7 | import shapeless.{::, HList, Strict, Witness} 8 | import shapeless.labelled.{FieldType, field} 9 | 10 | abstract class LowPriorityHListParserBuilder { 11 | 12 | implicit def hconsNoDefault[ 13 | K <: Symbol, 14 | H, 15 | T <: HList, 16 | PT <: HList, 17 | DT <: HList, 18 | NT <: HList, 19 | VT <: HList, 20 | MT <: HList, 21 | GT <: HList, 22 | HT <: HList, 23 | TT <: HList, 24 | RT <: HList 25 | ](implicit 26 | name: Witness.Aux[K], 27 | argParser: Strict[ArgParser[H]], 28 | tail: Strict[Aux[T, DT, NT, VT, MT, GT, HT, TT, RT, PT]] 29 | ): Aux[ 30 | FieldType[K, H] :: T, 31 | Option[H] :: DT, 32 | List[Name] :: NT, 33 | Option[ValueDescription] :: VT, 34 | Option[HelpMessage] :: MT, 35 | Option[Group] :: GT, 36 | Option[Hidden] :: HT, 37 | List[caseapp.Tag] :: TT, 38 | None.type :: RT, 39 | Option[H] :: PT 40 | ] = 41 | instance { (default0, names, valueDescriptions, helpMessages, groups, noHelp, tags, recurse) => 42 | 43 | val tailParser = tail.value( 44 | default0().tail, 45 | names.tail, 46 | valueDescriptions.tail, 47 | helpMessages.tail, 48 | groups.tail, 49 | noHelp.tail, 50 | tags.tail, 51 | recurse.tail 52 | ) 53 | 54 | val arg = Arg( 55 | Name(name.value.name), 56 | names.head, 57 | valueDescriptions.head.orElse(Some(new ValueDescription(argParser.value.description))), 58 | helpMessages.head, 59 | noHelp.head.nonEmpty, 60 | argParser.value.isFlag, 61 | groups.head 62 | ).withTags(tags.head) 63 | 64 | val argument = Argument(arg, argParser.value, () => default0().head) 65 | 66 | ConsParser(argument, tailParser) 67 | .mapHead(field[K](_)) 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: case-app 2 | 3 | nav: 4 | - Home: 'index.md' 5 | - 'setup.md' 6 | - API: 7 | - 'define.md' 8 | - 'types.md' 9 | - 'parse.md' 10 | - 'help.md' 11 | - 'commands.md' 12 | - 'completion.md' 13 | - 'misc.md' 14 | - 'advanced.md' 15 | - Contributing: 16 | - 'contrib-website.md' 17 | 18 | repo_url: https://github.com/alexarchambault/case-app 19 | edit_uri: edit/main/docs/pages/ 20 | 21 | theme: 22 | name: material 23 | palette: 24 | # scheme: slate 25 | 26 | # Palette toggle for automatic mode 27 | - media: "(prefers-color-scheme)" 28 | toggle: 29 | icon: material/brightness-auto 30 | name: Switch to light mode 31 | 32 | # Palette toggle for light mode 33 | - media: "(prefers-color-scheme: light)" 34 | scheme: default 35 | toggle: 36 | icon: material/brightness-7 37 | name: Switch to dark mode 38 | 39 | # Palette toggle for dark mode 40 | - media: "(prefers-color-scheme: dark)" 41 | scheme: slate 42 | toggle: 43 | icon: material/brightness-4 44 | name: Switch to system preference 45 | 46 | features: 47 | - content.action.edit 48 | - navigation.instant 49 | - navigation.instant.progress 50 | - navigation.tabs 51 | - navigation.path 52 | - navigation.top 53 | - search.suggest 54 | - search.highlight 55 | 56 | markdown_extensions: 57 | - pymdownx.highlight: 58 | anchor_linenums: true 59 | line_spans: __span 60 | pygments_lang_class: true 61 | - pymdownx.inlinehilite 62 | - pymdownx.snippets 63 | - pymdownx.superfences 64 | - tables 65 | - def_list 66 | - toc: 67 | permalink: true 68 | 69 | plugins: 70 | - git-revision-date-localized: 71 | enable_creation_date: true 72 | - git-committers: 73 | repository: alexarchambault/case-app 74 | branch: main 75 | enabled: !ENV [CI, false] 76 | - search 77 | 78 | extra: 79 | version: 80 | provider: mike 81 | 82 | validation: 83 | links: 84 | not_found: 85 | anchors: 86 | unrecognized_links: 87 | -------------------------------------------------------------------------------- /annotations/shared/src/main/scala/caseapp/Annotations.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | 3 | import scala.annotation.StaticAnnotation 4 | 5 | /** Extra name for the annotated argument 6 | */ 7 | final case class Name(name: String) extends StaticAnnotation 8 | 9 | /** Description of the value of the annotated argument 10 | */ 11 | final case class ValueDescription(description: String) extends StaticAnnotation { 12 | def message: String = s"<$description>" 13 | } 14 | 15 | object ValueDescription { 16 | val default = ValueDescription("value") 17 | } 18 | 19 | /** Help message for the annotated argument 20 | * @messageMd 21 | * not used by case-app itself, only there as a convenience for case-app users 22 | */ 23 | final case class HelpMessage(message: String, messageMd: String = "", detailedMessage: String = "") 24 | extends StaticAnnotation 25 | 26 | /** Name for the annotated case class of arguments E.g. MyApp 27 | */ 28 | final case class AppName(appName: String) extends StaticAnnotation 29 | 30 | /** Program name for the annotated case class of arguments E.g. my-app 31 | */ 32 | final case class ProgName(progName: String) extends StaticAnnotation 33 | 34 | /** Set the command name of the annotated case class of arguments E.g. my-app 35 | */ 36 | final case class CommandName(commandName: String) extends StaticAnnotation 37 | 38 | /** App version for the annotated case class of arguments 39 | */ 40 | final case class AppVersion(appVersion: String) extends StaticAnnotation 41 | 42 | /** Name for the extra arguments of the annotated case class of arguments 43 | */ 44 | final case class ArgsName(argsName: String) extends StaticAnnotation 45 | 46 | /** Don't parse the annotated field as a single argument. Recurse on its fields instead. 47 | * 48 | * Optionally, add a prefix to all the names of the fields. 49 | */ 50 | final case class Recurse(prefix: String) extends StaticAnnotation { 51 | def this() = this("") 52 | } 53 | 54 | object Recurse { 55 | def apply(): Recurse = 56 | new Recurse 57 | } 58 | 59 | /** Do not include this field / argument in the help message 60 | */ 61 | final class Hidden extends StaticAnnotation 62 | 63 | final case class Group(name: String) extends StaticAnnotation 64 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-3/caseapp/core/help/WithFullHelpCompanion.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.help 2 | 3 | import caseapp.{ExtraName, Group, HelpMessage, Parser, Recurse} 4 | import caseapp.core.Scala3Helpers.* 5 | import caseapp.core.parser.{Argument, NilParser, StandardArgument} 6 | import caseapp.core.{Arg, Error} 7 | import caseapp.core.parser.RecursiveConsParser 8 | import caseapp.core.util.Formatter 9 | 10 | abstract class WithFullHelpCompanion { 11 | 12 | implicit def parser[T](implicit 13 | underlying: Parser[T] 14 | ): Parser[WithFullHelp[T]] = { 15 | 16 | val baseHelpArgument = StandardArgument[Boolean]( 17 | Arg("helpFull") 18 | .withExtraNames(Seq( 19 | ExtraName("fullHelp"), 20 | ExtraName("-help-full"), 21 | ExtraName("-full-help") 22 | )) 23 | .withGroup(Some(Group("Help"))) 24 | .withOrigin(Some("WithFullHelp")) 25 | .withHelpMessage(Some( 26 | HelpMessage("Print help message, including hidden options, and exit") 27 | )) 28 | .withIsFlag(true) 29 | ).withDefault(() => Some(false)) 30 | 31 | // accept "-help" too (single dash) 32 | val helpArgument: Argument[Boolean] = 33 | new Argument[Boolean] { 34 | def arg = baseHelpArgument.arg 35 | def withDefaultOrigin(origin: String) = 36 | this 37 | def init = baseHelpArgument.init 38 | def step( 39 | args: List[String], 40 | index: Int, 41 | d: Option[Boolean], 42 | nameFormatter: Formatter[ExtraName] 43 | ): Either[(Error, List[String]), Option[(Option[Boolean], List[String])]] = 44 | args match { 45 | case ("-help-full" | "-full-help") :: rem => Right(Some((Some(true), rem))) 46 | case _ => baseHelpArgument.step(args, index, d, nameFormatter) 47 | } 48 | def get(d: Option[Boolean], nameFormatter: Formatter[ExtraName]) = 49 | baseHelpArgument.get(d, nameFormatter) 50 | } 51 | 52 | val withHelpParser = WithHelp.parser[T](underlying) 53 | 54 | val p = RecursiveConsParser(withHelpParser, helpArgument :: NilParser, Recurse()) 55 | 56 | p.to[WithFullHelp[T]] 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/complete/Zsh.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.complete 2 | 3 | import scala.util.hashing.MurmurHash3 4 | 5 | object Zsh { 6 | 7 | val shellName: String = 8 | "zsh" 9 | val id: String = 10 | s"$shellName-v1" 11 | 12 | def script(progName: String): String = 13 | s"""#compdef _$progName $progName 14 | | 15 | |function _$progName { 16 | | eval "$$($progName complete $id $$CURRENT $$words[@])" 17 | |} 18 | |""".stripMargin 19 | 20 | private def hash(content: Iterator[String]): String = { 21 | val hash = MurmurHash3.arrayHash(content.toArray) 22 | if (hash < 0) (hash * -1).toString 23 | else hash.toString 24 | } 25 | private def escape(input: String): String = 26 | input 27 | .replace("'", "\\'") 28 | .replace("`", "\\`") 29 | .replace("|", "\\|") 30 | .linesIterator 31 | .take(1) 32 | .toList 33 | .headOption 34 | .getOrElse("") 35 | private def defs(item: CompletionItem): Seq[String] = { 36 | val (options, arguments) = item.values.partition(_.startsWith("-")) 37 | val optionsOutput = 38 | if (options.isEmpty) Nil 39 | else { 40 | val escapedOptions = options 41 | val desc = item.description.map(desc => ":" + escape(desc)).getOrElse("") 42 | options.map { opt => 43 | "\"" + opt + desc + "\"" 44 | } 45 | } 46 | val argumentsOutput = 47 | if (arguments.isEmpty) Nil 48 | else { 49 | val desc = item.description.map(desc => ":" + escape(desc)).getOrElse("") 50 | arguments.map("'" + _.replace(":", "\\:") + desc + "'") 51 | } 52 | optionsOutput ++ argumentsOutput 53 | } 54 | 55 | private def render(commands: Seq[String]): String = 56 | if (commands.isEmpty) "_files" + System.lineSeparator() 57 | else { 58 | val id = hash(commands.iterator) 59 | s"""local -a args$id 60 | |args$id=( 61 | |${commands.mkString(System.lineSeparator())} 62 | |) 63 | |_describe command args$id 64 | |""".stripMargin 65 | } 66 | def print(items: Seq[CompletionItem]): String = 67 | render(items.flatMap(defs(_))) 68 | } 69 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-2/caseapp/core/parser/RecursiveConsParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.{Name, Recurse} 4 | import caseapp.core.{Arg, Error} 5 | import caseapp.core.util.Formatter 6 | import shapeless.{::, HList} 7 | import dataclass.data 8 | 9 | @data class RecursiveConsParser[H, HD, T <: HList, TD <: HList]( 10 | headParser: Parser.Aux[H, HD], 11 | tailParser: Parser.Aux[T, TD], 12 | recurse: Recurse 13 | ) extends Parser[H :: T] { 14 | 15 | type D = HD :: TD 16 | 17 | def init: D = 18 | headParser.init :: tailParser.init 19 | 20 | def step( 21 | args: List[String], 22 | index: Int, 23 | d: D, 24 | nameFormatter: Formatter[Name] 25 | ): Either[(Error, Arg, List[String]), Option[(D, Arg, List[String])]] = 26 | headParser 27 | .step(args, index, d.head, Formatter.addRecursePrefix(recurse, nameFormatter)) 28 | .flatMap { 29 | case None => 30 | tailParser 31 | .step(args, index, d.tail, nameFormatter) 32 | .map(_.map { 33 | case (t, arg, args) => (d.head :: t, arg, args) 34 | }) 35 | case Some((h, arg, args)) => 36 | Right(Some(h :: d.tail, arg, args)) 37 | } 38 | 39 | def get(d: D, nameFormatter: Formatter[Name]): Either[Error, H :: T] = { 40 | val maybeHead = headParser.get(d.head, nameFormatter) 41 | val maybeTail = tailParser.get(d.tail, nameFormatter) 42 | 43 | (maybeHead, maybeTail) match { 44 | case (Left(headErrs), Left(tailErrs)) => Left(headErrs.append(tailErrs)) 45 | case (Left(headErrs), _) => Left(headErrs) 46 | case (_, Left(tailErrs)) => Left(tailErrs) 47 | case (Right(h), Right(t)) => Right(h :: t) 48 | } 49 | } 50 | 51 | val args = headParser.args ++ tailParser.args 52 | 53 | def mapHead[I](f: H => I): Parser.Aux[I :: T, D] = 54 | map { l => 55 | f(l.head) :: l.tail 56 | } 57 | 58 | def ::[A](argument: Argument[A]): ConsParser[A, H :: T, D] = 59 | ConsParser[A, H :: T, D](argument, this) 60 | 61 | def withDefaultOrigin(origin: String): Parser.Aux[H :: T, D] = 62 | withHeadParser(headParser.withDefaultOrigin(origin)) 63 | .withTailParser(tailParser.withDefaultOrigin(origin)) 64 | } 65 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-2/caseapp/core/parser/ConsParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.argparser.ArgParser 4 | import caseapp.core.{Arg, Error} 5 | import caseapp.core.util.NameOps.toNameOps 6 | import shapeless.{:: => :*:, HList} 7 | import dataclass.data 8 | import caseapp.core.util.Formatter 9 | import caseapp.Name 10 | 11 | @data class ConsParser[H, T <: HList, DT <: HList]( 12 | argument: Argument[H], 13 | tail: Parser.Aux[T, DT] 14 | ) extends Parser[H :*: T] { 15 | 16 | type D = Option[H] :*: DT 17 | 18 | def init: D = 19 | argument.init :: tail.init 20 | 21 | def step( 22 | args: List[String], 23 | index: Int, 24 | d: Option[H] :*: tail.D, 25 | nameFormatter: Formatter[Name] 26 | ): Either[(Error, Arg, List[String]), Option[(D, Arg, List[String])]] = 27 | argument.step(args, index, d.head, nameFormatter) match { 28 | case Left((err, rem)) => Left((err, argument.arg, rem)) 29 | case Right(Some((dHead, rem))) => 30 | Right(Some((dHead :: d.tail, argument.arg, rem))) 31 | case Right(None) => 32 | tail 33 | .step(args, index, d.tail, nameFormatter) 34 | .map(_.map { 35 | case (t, arg, args) => (d.head :: t, arg, args) 36 | }) 37 | } 38 | 39 | def get(d: D, nameFormatter: Formatter[Name]): Either[Error, H :*: T] = { 40 | 41 | val maybeHead = argument.get(d.head, nameFormatter) 42 | val maybeTail = tail.get(d.tail) 43 | 44 | (maybeHead, maybeTail) match { 45 | case (Left(headErr), Left(tailErrs)) => Left(headErr.append(tailErrs)) 46 | case (Left(headErr), _) => Left(headErr) 47 | case (_, Left(tailErrs)) => Left(tailErrs) 48 | case (Right(h), Right(t)) => Right(h :: t) 49 | } 50 | } 51 | 52 | val args: Seq[Arg] = 53 | argument.arg +: tail.args 54 | 55 | def mapHead[I](f: H => I): Parser.Aux[I :*: T, D] = 56 | map { l => 57 | f(l.head) :: l.tail 58 | } 59 | 60 | def ::[A](argument: Argument[A]): ConsParser[A, H :*: T, D] = 61 | ConsParser[A, H :*: T, D](argument, this) 62 | 63 | def withDefaultOrigin(origin: String): Parser.Aux[H :*: T, D] = 64 | withArgument(argument.withDefaultOrigin(origin)) 65 | .withTail(tail.withDefaultOrigin(origin)) 66 | } 67 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/argparser/FlagAccumulatorArgParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.argparser 2 | 3 | import caseapp.core.{Counter, Error} 4 | import caseapp.{@@, Tag} 5 | import dataclass.data 6 | 7 | @data case class FlagAccumulatorArgParser[T]( 8 | description: String, 9 | parse: (Option[T], Int, Int, Option[String]) => Either[Error, T] 10 | ) extends ArgParser[T] { 11 | 12 | def apply(current: Option[T], index: Int, span: Int, value: String): Either[Error, T] = 13 | parse(current, index, span, Some(value)) 14 | 15 | override def optional( 16 | current: Option[T], 17 | index: Int, 18 | span: Int, 19 | value: String 20 | ): (Consumed, Either[Error, T]) = 21 | (Consumed(false), parse(current, index, span, None)) 22 | 23 | override def apply(current: Option[T], index: Int): Either[Error, T] = 24 | parse(current, index, 1, None) 25 | 26 | override def isFlag: Boolean = 27 | true 28 | 29 | } 30 | 31 | object FlagAccumulatorArgParser { 32 | 33 | def from[T]( 34 | description: String 35 | )( 36 | parse: (Option[T], Int, Int, Option[String]) => Either[Error, T] 37 | ): FlagAccumulatorArgParser[T] = 38 | FlagAccumulatorArgParser(description, parse) 39 | 40 | val counter: FlagAccumulatorArgParser[Int @@ Counter] = 41 | from("counter") { (prevOpt, _, _, _) => 42 | Right(Tag.of(prevOpt.fold(0)(Tag.unwrap) + 1)) 43 | } 44 | 45 | def list[T](implicit parser: ArgParser[T]): FlagAccumulatorArgParser[List[T]] = 46 | from(parser.description + "*") { (prevOpt, idx, span, s) => 47 | s.fold(parser(None, idx))(parser(None, idx, span, _)).map { t => 48 | // inefficient for big lists 49 | prevOpt.getOrElse(Nil) :+ t 50 | } 51 | } 52 | 53 | def vector[T](implicit parser: ArgParser[T]): FlagAccumulatorArgParser[Vector[T]] = 54 | from(parser.description + "*") { (prevOpt, idx, span, s) => 55 | s.fold(parser(None, idx))(parser(None, idx, span, _)).map { t => 56 | prevOpt.getOrElse(Vector.empty) :+ t 57 | } 58 | } 59 | 60 | def option[T](implicit parser: ArgParser[T]): FlagAccumulatorArgParser[Option[T]] = 61 | from(parser.description + "?") { (prevOpt, idx, span, s) => 62 | s.fold(parser(prevOpt.flatten, idx))(parser(prevOpt.flatten, idx, span, _)) 63 | .map(Some(_)) 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-2/caseapp/core/help/WithFullHelpCompanion.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.help 2 | 3 | import caseapp.{ExtraName, Group, HelpMessage, Parser, Recurse} 4 | import caseapp.core.parser.{Argument, NilParser, StandardArgument} 5 | import caseapp.core.{Arg, Error} 6 | import caseapp.core.parser.RecursiveConsParser 7 | import caseapp.core.util.Formatter 8 | 9 | import shapeless.{HNil, :: => :*:} 10 | 11 | abstract class WithFullHelpCompanion { 12 | 13 | implicit def parser[T, D](implicit 14 | underlying: Parser.Aux[T, D] 15 | ): Parser.Aux[ 16 | WithFullHelp[T], 17 | (Option[Boolean] :*: Option[Boolean] :*: D :*: HNil) :*: Option[Boolean] :*: HNil 18 | ] = { 19 | 20 | val baseHelpArgument = StandardArgument[Boolean]( 21 | Arg("helpFull") 22 | .withExtraNames(Seq( 23 | ExtraName("fullHelp"), 24 | ExtraName("-help-full"), 25 | ExtraName("-full-help") 26 | )) 27 | .withGroup(Some(Group("Help"))) 28 | .withOrigin(Some("WithFullHelp")) 29 | .withHelpMessage(Some( 30 | HelpMessage("Print help message, including hidden options, and exit") 31 | )) 32 | .withIsFlag(true) 33 | ).withDefault(() => Some(false)) 34 | 35 | // accept "-help" too (single dash) 36 | val helpArgument: Argument[Boolean] = 37 | new Argument[Boolean] { 38 | def arg = baseHelpArgument.arg 39 | def withDefaultOrigin(origin: String) = 40 | this 41 | def init = baseHelpArgument.init 42 | def step( 43 | args: List[String], 44 | index: Int, 45 | d: Option[Boolean], 46 | nameFormatter: Formatter[ExtraName] 47 | ): Either[(Error, List[String]), Option[(Option[Boolean], List[String])]] = 48 | args match { 49 | case ("-help-full" | "-full-help") :: rem => Right(Some((Some(true), rem))) 50 | case _ => baseHelpArgument.step(args, index, d, nameFormatter) 51 | } 52 | def get(d: Option[Boolean], nameFormatter: Formatter[ExtraName]) = 53 | baseHelpArgument.get(d, nameFormatter) 54 | } 55 | 56 | val withHelpParser = WithHelp.parser[T, D](underlying) 57 | 58 | val p = RecursiveConsParser(withHelpParser, helpArgument :: NilParser, Recurse()) 59 | 60 | p.to[WithFullHelp[T]] 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-3/caseapp/core/parser/ParserOps.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.{HelpMessage, Name, Recurse, ValueDescription} 4 | import caseapp.core.argparser.ArgParser 5 | import caseapp.core.Arg 6 | import caseapp.core.util.Formatter 7 | import scala.deriving.Mirror 8 | 9 | class ParserOps[T <: Tuple](val parser: Parser[T]) extends AnyVal { 10 | 11 | // FIXME group is missing 12 | def add[H: ArgParser]( 13 | name: String, 14 | default: => Option[H] = None, 15 | extraNames: Seq[Name] = Nil, 16 | valueDescription: Option[ValueDescription] = None, 17 | helpMessage: Option[HelpMessage] = None, 18 | noHelp: Boolean = false, 19 | isFlag: Boolean = false, 20 | formatter: Formatter[Name] = Formatter.DefaultNameFormatter 21 | ): Parser[H *: T] = { 22 | val argument = Argument( 23 | Arg( 24 | Name(name), 25 | extraNames, 26 | valueDescription, 27 | helpMessage, 28 | noHelp, 29 | isFlag 30 | ), 31 | ArgParser[H], 32 | () => default 33 | ) 34 | ConsParser(argument, parser) 35 | } 36 | 37 | def addAll[H](using headParser: Parser[H]): Parser[H *: T] = 38 | RecursiveConsParser(headParser, parser, Recurse()) 39 | 40 | def as[F](using 41 | m: Mirror.ProductOf[F], 42 | ev: T =:= ParserOps.Reverse[m.MirroredElemTypes], 43 | ev0: ParserOps.Reverse[ParserOps.Reverse[m.MirroredElemTypes]] =:= m.MirroredElemTypes 44 | ): Parser[F] = 45 | parser 46 | .map(ev) 47 | .map(ParserOps.reverse[ParserOps.Reverse[m.MirroredElemTypes]]) 48 | .map(ev0) 49 | .map(m.fromTuple) 50 | 51 | def tupled: Parser[ParserOps.Reverse[T]] = 52 | parser.map(ParserOps.reverse) 53 | 54 | def to[F](using 55 | m: Mirror.ProductOf[F], 56 | ev: T =:= m.MirroredElemTypes 57 | ): Parser[F] = 58 | parser.map(ev).map(m.fromTuple) 59 | 60 | def toTuple[P <: Tuple](using 61 | m: Mirror.ProductOf[T] { type MirroredElemTypes = P } 62 | ): Parser[P] = 63 | parser.map(Tuple.fromProductTyped[T]) 64 | 65 | } 66 | 67 | object ParserOps { 68 | 69 | type Reverse[T <: Tuple] <: Tuple = T match { 70 | case EmptyTuple => EmptyTuple 71 | case x *: xs => Tuple.Concat[Reverse[xs], x *: EmptyTuple] 72 | } 73 | 74 | def reverse[T <: Tuple](t: T): Reverse[T] = 75 | Tuple.fromArray(t.toArray.reverse).asInstanceOf[Reverse[T]] 76 | 77 | } 78 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-2/caseapp/core/help/WithHelpCompanion.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.help 2 | 3 | import caseapp.{ExtraName, Group, HelpMessage, Parser, Recurse} 4 | import caseapp.core.parser.{Argument, NilParser, StandardArgument} 5 | import caseapp.core.{Arg, Error} 6 | import caseapp.core.parser.{EitherParser, RecursiveConsParser} 7 | import caseapp.core.util.Formatter 8 | 9 | import shapeless.{HNil, :: => :*:} 10 | 11 | abstract class WithHelpCompanion { 12 | 13 | implicit def parser[T, D](implicit 14 | underlying: Parser.Aux[T, D] 15 | ): Parser.Aux[WithHelp[T], Option[Boolean] :*: Option[Boolean] :*: D :*: HNil] = { 16 | 17 | val usageArgument = StandardArgument[Boolean]( 18 | Arg("usage") 19 | .withGroup(Some(Group("Help"))) 20 | .withOrigin(Some("WithHelp")) 21 | .withHelpMessage(Some(HelpMessage("Print usage and exit"))) 22 | .withIsFlag(true) 23 | ).withDefault(() => Some(false)) 24 | 25 | val baseHelpArgument = StandardArgument[Boolean]( 26 | Arg("help") 27 | .withExtraNames(Seq(ExtraName("h"), ExtraName("-help"))) 28 | .withGroup(Some(Group("Help"))) 29 | .withOrigin(Some("WithHelp")) 30 | .withHelpMessage(Some(HelpMessage("Print help message and exit"))) 31 | .withIsFlag(true) 32 | ).withDefault(() => Some(false)) 33 | 34 | // accept "-help" too (single dash) 35 | val helpArgument: Argument[Boolean] = 36 | new Argument[Boolean] { 37 | def arg = baseHelpArgument.arg 38 | def withDefaultOrigin(origin: String) = 39 | this 40 | def init = baseHelpArgument.init 41 | def step( 42 | args: List[String], 43 | index: Int, 44 | d: Option[Boolean], 45 | nameFormatter: Formatter[ExtraName] 46 | ): Either[(Error, List[String]), Option[(Option[Boolean], List[String])]] = 47 | args match { 48 | case "-help" :: rem => Right(Some((Some(true), rem))) 49 | case _ => baseHelpArgument.step(args, index, d, nameFormatter) 50 | } 51 | def get(d: Option[Boolean], nameFormatter: Formatter[ExtraName]) = 52 | baseHelpArgument.get(d, nameFormatter) 53 | } 54 | 55 | val either = EitherParser[T, D](underlying) 56 | 57 | val p = usageArgument :: 58 | helpArgument :: 59 | RecursiveConsParser(either, NilParser, Recurse()) 60 | 61 | p.to[WithHelp[T]] 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-3/caseapp/core/help/WithHelpCompanion.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.help 2 | 3 | import caseapp.{ExtraName, Group, Help, HelpMessage, Parser, Recurse} 4 | import caseapp.core.Scala3Helpers.* 5 | import caseapp.core.parser.{Argument, NilParser, StandardArgument} 6 | import caseapp.core.{Arg, Error} 7 | import caseapp.core.parser.{EitherParser, RecursiveConsParser} 8 | import caseapp.core.util.Formatter 9 | 10 | abstract class WithHelpCompanion { 11 | 12 | implicit def parser[T](implicit 13 | underlying: Parser[T] 14 | ): Parser[WithHelp[T]] = { 15 | 16 | val usageArgument = StandardArgument[Boolean]( 17 | Arg("usage") 18 | .withGroup(Some(Group("Help"))) 19 | .withOrigin(Some("WithHelp")) 20 | .withHelpMessage(Some(HelpMessage("Print usage and exit"))) 21 | .withIsFlag(true) 22 | ).withDefault(() => Some(false)) 23 | 24 | val baseHelpArgument = StandardArgument[Boolean]( 25 | Arg("help") 26 | .withExtraNames(Seq(ExtraName("h"), ExtraName("-help"))) 27 | .withGroup(Some(Group("Help"))) 28 | .withOrigin(Some("WithHelp")) 29 | .withHelpMessage(Some(HelpMessage("Print help message and exit"))) 30 | .withIsFlag(true) 31 | ).withDefault(() => Some(false)) 32 | 33 | // accept "-help" too (single dash) 34 | val helpArgument: Argument[Boolean] = 35 | new Argument[Boolean] { 36 | def arg = baseHelpArgument.arg 37 | def withDefaultOrigin(origin: String) = 38 | this 39 | def init = baseHelpArgument.init 40 | def step( 41 | args: List[String], 42 | index: Int, 43 | d: Option[Boolean], 44 | nameFormatter: Formatter[ExtraName] 45 | ): Either[(Error, List[String]), Option[(Option[Boolean], List[String])]] = 46 | args match { 47 | case "-help" :: rem => Right(Some((Some(true), rem))) 48 | case _ => baseHelpArgument.step(args, index, d, nameFormatter) 49 | } 50 | def get(d: Option[Boolean], nameFormatter: Formatter[ExtraName]) = 51 | baseHelpArgument.get(d, nameFormatter) 52 | } 53 | 54 | val either = EitherParser[T](underlying) 55 | 56 | val p = usageArgument :: 57 | helpArgument :: 58 | RecursiveConsParser(either, NilParser, Recurse()) 59 | 60 | p.to[WithHelp[T]] 61 | } 62 | 63 | implicit def help[T, D](implicit 64 | parser: Parser[T], 65 | underlying: Help[T] 66 | ): Help[WithHelp[T]] = 67 | Help.derive[WithHelp[T]] 68 | } 69 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-3/caseapp/core/parser/ConsParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.argparser.ArgParser 4 | import caseapp.core.Scala3Helpers._ 5 | import caseapp.core.{Arg, Error} 6 | import caseapp.core.util.NameOps.toNameOps 7 | import dataclass.data 8 | import caseapp.core.util.Formatter 9 | import caseapp.Name 10 | 11 | import scala.compiletime._ 12 | import scala.compiletime.ops._ 13 | 14 | case class ConsParser[H, T <: Tuple]( 15 | argument: Argument[H], 16 | val tail: Parser[T] 17 | ) extends Parser[H *: T] { 18 | 19 | type D = Option[H] *: tail.D 20 | 21 | def init: D = 22 | argument.init *: tail.init 23 | 24 | def step( 25 | args: List[String], 26 | index: Int, 27 | d: D, 28 | nameFormatter: Formatter[Name] 29 | ): Either[(Error, Arg, List[String]), Option[(D, Arg, List[String])]] = 30 | argument.step(args, index, runtime.Tuples(d, 0).asInstanceOf[Option[H]], nameFormatter) match { 31 | case Left((err, rem)) => Left((err, argument.arg, rem)) 32 | case Right(Some((dHead, rem))) => 33 | Right(Some((dHead *: runtime.Tuples.tail(d).asInstanceOf[tail.D], argument.arg, rem))) 34 | case Right(None) => 35 | tail 36 | .step(args, index, runtime.Tuples.tail(d).asInstanceOf[tail.D], nameFormatter) 37 | .map(_.map { 38 | case (t, arg, args) => (runtime.Tuples(d, 0).asInstanceOf[Option[H]] *: t, arg, args) 39 | }) 40 | } 41 | 42 | def get(d: D, nameFormatter: Formatter[Name]): Either[Error, H *: T] = { 43 | 44 | val maybeHead = argument.get(runtime.Tuples(d, 0).asInstanceOf[Option[H]], nameFormatter) 45 | val maybeTail = tail.get(runtime.Tuples.tail(d).asInstanceOf[tail.D]) 46 | 47 | (maybeHead, maybeTail) match { 48 | case (Left(headErr), Left(tailErrs)) => Left(headErr.append(tailErrs)) 49 | case (Left(headErr), _) => Left(headErr) 50 | case (_, Left(tailErrs)) => Left(tailErrs) 51 | case (Right(h), Right(t)) => Right(h *: t) 52 | } 53 | } 54 | 55 | val args: Seq[Arg] = 56 | argument.arg +: tail.args 57 | 58 | def mapHead[I](f: H => I): Parser[I *: T] = 59 | map { l => 60 | f(runtime.Tuples.apply(l, 0).asInstanceOf[H]) *: runtime.Tuples.tail(l).asInstanceOf[T] 61 | } 62 | 63 | def ::[A](argument: Argument[A]): ConsParser[A, H *: T] = 64 | ConsParser[A, H *: T](argument, this) 65 | 66 | def withDefaultOrigin(origin: String): Parser[H *: T] = 67 | this.withArgument(argument.withDefaultOrigin(origin)) 68 | .withTail(tail.withDefaultOrigin(origin)) 69 | } 70 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-3/caseapp/core/parser/RecursiveConsParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.{Name, Recurse} 4 | import caseapp.core.{Arg, Error} 5 | import caseapp.core.util.Formatter 6 | import caseapp.core.Scala3Helpers._ 7 | import dataclass.data 8 | 9 | case class RecursiveConsParser[H, T <: Tuple]( 10 | headParser: Parser[H], 11 | tailParser: Parser[T], 12 | recurse: Recurse 13 | ) extends Parser[H *: T] { 14 | 15 | type D = headParser.D *: tailParser.D 16 | 17 | def init: D = 18 | headParser.init *: tailParser.init 19 | 20 | def step( 21 | args: List[String], 22 | index: Int, 23 | d: D, 24 | nameFormatter: Formatter[Name] 25 | ): Either[(Error, Arg, List[String]), Option[(D, Arg, List[String])]] = 26 | headParser 27 | .step( 28 | args, 29 | index, 30 | runtime.Tuples(d, 0).asInstanceOf[headParser.D], 31 | Formatter.addRecursePrefix(recurse, nameFormatter) 32 | ) 33 | .flatMap { 34 | case None => 35 | tailParser 36 | .step(args, index, runtime.Tuples.tail(d).asInstanceOf[tailParser.D], nameFormatter) 37 | .map(_.map { 38 | case (t, arg, args) => 39 | (runtime.Tuples(d, 0).asInstanceOf[headParser.D] *: t, arg, args) 40 | }) 41 | case Some((h, arg, args)) => 42 | Right(Some(h *: runtime.Tuples.tail(d).asInstanceOf[tailParser.D], arg, args)) 43 | } 44 | 45 | def get(d: D, nameFormatter: Formatter[Name]): Either[Error, H *: T] = { 46 | val maybeHead = headParser.get(runtime.Tuples(d, 0).asInstanceOf[headParser.D], nameFormatter) 47 | val maybeTail = tailParser.get(runtime.Tuples.tail(d).asInstanceOf[tailParser.D], nameFormatter) 48 | 49 | (maybeHead, maybeTail) match { 50 | case (Left(headErrs), Left(tailErrs)) => Left(headErrs.append(tailErrs)) 51 | case (Left(headErrs), _) => Left(headErrs) 52 | case (_, Left(tailErrs)) => Left(tailErrs) 53 | case (Right(h), Right(t)) => Right(h *: t) 54 | } 55 | } 56 | 57 | val args = headParser.args ++ tailParser.args 58 | 59 | def mapHead[I](f: H => I): Parser[I *: T] = 60 | map { l => 61 | f(l.head) *: runtime.Tuples.tail(l).asInstanceOf[T] 62 | } 63 | 64 | def ::[A](argument: Argument[A]): ConsParser[A, H *: T] = 65 | ConsParser[A, H *: T](argument, this) 66 | 67 | def withDefaultOrigin(origin: String): Parser[H *: T] = 68 | this.withHeadParser(headParser.withDefaultOrigin(origin)) 69 | .withTailParser(tailParser.withDefaultOrigin(origin)) 70 | } 71 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/argparser/SimpleArgParser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.argparser 2 | 3 | import caseapp.core.Error 4 | import dataclass.data 5 | 6 | @data case class SimpleArgParser[T]( 7 | description: String, 8 | parse: (String, Int, Int) => Either[Error, T] 9 | ) extends ArgParser[T] { 10 | 11 | def apply(current: Option[T], index: Int, span: Int, value: String): Either[Error, T] = 12 | current match { 13 | case None => 14 | parse(value, index, span) 15 | case Some(_) => 16 | Left(Error.ArgumentAlreadySpecified("???")) 17 | } 18 | 19 | } 20 | 21 | object SimpleArgParser { 22 | 23 | def from[T](description: String)(parse: String => Either[Error, T]): SimpleArgParser[T] = 24 | SimpleArgParser(description, (value, _, _) => parse(value)) 25 | 26 | val byte: SimpleArgParser[Byte] = 27 | from("byte") { s => 28 | try Right(s.toByte) 29 | catch { 30 | case _: NumberFormatException => 31 | Left(Error.MalformedValue("byte-sized integer", s)) 32 | } 33 | } 34 | 35 | val short: SimpleArgParser[Short] = 36 | from("short") { s => 37 | try Right(s.toShort) 38 | catch { 39 | case _: NumberFormatException => 40 | Left(Error.MalformedValue("short integer", s)) 41 | } 42 | } 43 | 44 | val int: SimpleArgParser[Int] = 45 | from("int") { s => 46 | try Right(s.toInt) 47 | catch { 48 | case _: NumberFormatException => 49 | Left(Error.MalformedValue("integer", s)) 50 | } 51 | } 52 | 53 | val long: SimpleArgParser[Long] = 54 | from("long") { s => 55 | try Right(s.toLong) 56 | catch { 57 | case _: NumberFormatException => 58 | Left(Error.MalformedValue("long integer", s)) 59 | } 60 | } 61 | 62 | val double: SimpleArgParser[Double] = 63 | from("double") { s => 64 | try Right(s.toDouble) 65 | catch { 66 | case _: NumberFormatException => 67 | Left(Error.MalformedValue("double float", s)) 68 | } 69 | } 70 | 71 | val float: SimpleArgParser[Float] = 72 | from("float") { s => 73 | try Right(s.toFloat) 74 | catch { 75 | case _: NumberFormatException => 76 | Left(Error.MalformedValue("float", s)) 77 | } 78 | } 79 | 80 | val bigDecimal: SimpleArgParser[BigDecimal] = 81 | from("decimal") { s => 82 | try Right(BigDecimal(s)) 83 | catch { 84 | case _: NumberFormatException => 85 | Left(Error.MalformedValue("decimal", s)) 86 | } 87 | } 88 | 89 | val string: SimpleArgParser[String] = 90 | from("string")(Right(_)) 91 | 92 | } 93 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/Error.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core 2 | 3 | import caseapp.Name 4 | import caseapp.core.Scala3Helpers._ 5 | import caseapp.core.util.NameOps.toNameOps 6 | import dataclass.data 7 | import caseapp.core.util.Formatter 8 | 9 | /** Base type for errors during arguments parsing */ 10 | sealed abstract class Error extends Product with Serializable { 11 | def message: String 12 | def append(that: Error): Error 13 | } 14 | 15 | object Error { 16 | 17 | sealed abstract class SimpleError(val message: String) extends Error { 18 | def append(that: Error): Error = that match { 19 | case simple: SimpleError => Error.SeveralErrors(this, Seq(simple)) 20 | case s: Error.SeveralErrors => 21 | Error.SeveralErrors(this, s.head +: s.tail) 22 | } 23 | } 24 | 25 | @data case class SeveralErrors(head: SimpleError, tail: Seq[SimpleError]) extends Error { 26 | def message: String = (head +: tail).map(_.message).mkString("\n") 27 | def append(that: Error): Error = that match { 28 | case simple: SimpleError => this.withTail(tail :+ simple) 29 | case s: Error.SeveralErrors => 30 | this.withTail(tail ++ (s.head +: s.tail)) 31 | } 32 | } 33 | 34 | // FIXME These could be made more precise (stating which argument failed, at which position, etc.) 35 | 36 | case object ArgumentMissing extends SimpleError("argument missing") 37 | 38 | @data case class ArgumentAlreadySpecified(name: String, extraNames: Seq[String] = Nil) 39 | extends SimpleError(s"argument ${(name +: extraNames).mkString(" / ")} already specified") 40 | 41 | case object CannotBeDisabled extends SimpleError("Option cannot be explicitly disabled") 42 | 43 | @data case class UnrecognizedFlagValue(value: String) 44 | extends SimpleError(s"Unrecognized flag value: $value") 45 | 46 | @data case class UnrecognizedArgument(arg: String) 47 | extends SimpleError(s"Unrecognized argument: $arg") 48 | 49 | @data case class CommandNotFound(command: String) 50 | extends SimpleError(s"Command not found: $command") 51 | 52 | @data case class RequiredOptionNotSpecified(name: String, extraNames: Seq[String] = Nil) 53 | extends SimpleError(s"Required option ${(name +: extraNames).mkString(" / ")} not specified") 54 | 55 | @data case class MalformedValue(`type`: String, error: String) 56 | extends SimpleError(s"Malformed ${`type`}: $error") 57 | 58 | @data case class Other(override val message: String) extends SimpleError(message) 59 | @data case class ParsingArgument(name: Name, error: Error, nameFormatter: Formatter[Name]) 60 | extends SimpleError( 61 | s"Argument ${name.option(nameFormatter)}: ${error.message}" 62 | ) 63 | 64 | } 65 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-2/caseapp/core/parser/LowPriorityParserImplicits.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.{Group, HelpMessage, Hidden, Name, Recurse, ValueDescription} 4 | import caseapp.util.AnnotationList 5 | import shapeless.{Annotations, HList, LabelledGeneric, Strict, Typeable} 6 | 7 | trait LowPriorityParserImplicits { 8 | 9 | def derive[ 10 | CC, 11 | L <: HList, 12 | D <: HList, 13 | N <: HList, 14 | V <: HList, 15 | M <: HList, 16 | G <: HList, 17 | H <: HList, 18 | T <: HList, 19 | R <: HList, 20 | P <: HList 21 | ](implicit 22 | gen: LabelledGeneric.Aux[CC, L], 23 | typeable: Typeable[CC], 24 | defaults: caseapp.util.Default.AsOptions.Aux[CC, D], 25 | names: AnnotationList.Aux[Name, CC, N], 26 | valuesDesc: Annotations.Aux[ValueDescription, CC, V], 27 | helpMessages: Annotations.Aux[HelpMessage, CC, M], 28 | group: Annotations.Aux[Group, CC, G], 29 | noHelp: Annotations.Aux[Hidden, CC, H], 30 | tags: AnnotationList.Aux[caseapp.Tag, CC, T], 31 | recurse: Annotations.Aux[Recurse, CC, R], 32 | parser: Strict[HListParserBuilder.Aux[L, D, N, V, M, G, H, T, R, P]] 33 | ): Parser.Aux[CC, P] = 34 | parser 35 | .value 36 | .apply( 37 | defaults(), 38 | names(), 39 | valuesDesc(), 40 | helpMessages(), 41 | group(), 42 | noHelp(), 43 | tags(), 44 | recurse() 45 | ) 46 | .map(gen.from) 47 | .withDefaultOrigin(typeable.describe) 48 | 49 | implicit def generic[ 50 | CC, 51 | L <: HList, 52 | D <: HList, 53 | N <: HList, 54 | V <: HList, 55 | M <: HList, 56 | G <: HList, 57 | H <: HList, 58 | T <: HList, 59 | R <: HList, 60 | P <: HList 61 | ](implicit 62 | lowPriority: caseapp.util.LowPriority, 63 | gen: LabelledGeneric.Aux[CC, L], 64 | typeable: Typeable[CC], 65 | defaults: caseapp.util.Default.AsOptions.Aux[CC, D], 66 | names: AnnotationList.Aux[Name, CC, N], 67 | valuesDesc: Annotations.Aux[ValueDescription, CC, V], 68 | helpMessages: Annotations.Aux[HelpMessage, CC, M], 69 | group: Annotations.Aux[Group, CC, G], 70 | noHelp: Annotations.Aux[Hidden, CC, H], 71 | tags: AnnotationList.Aux[caseapp.Tag, CC, T], 72 | recurse: Annotations.Aux[Recurse, CC, R], 73 | parser: Strict[HListParserBuilder.Aux[L, D, N, V, M, G, H, T, R, P]] 74 | ): Parser.Aux[CC, P] = 75 | derive[CC, L, D, N, V, M, G, H, T, R, P]( 76 | gen, 77 | typeable, 78 | defaults, 79 | names, 80 | valuesDesc, 81 | helpMessages, 82 | group, 83 | noHelp, 84 | tags, 85 | recurse, 86 | parser 87 | ) 88 | 89 | } 90 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-3/caseapp/core/parser/Parser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.Error 4 | import caseapp.core.help.{WithFullHelp, WithHelp} 5 | import caseapp.core.util.Formatter 6 | import caseapp.Name 7 | 8 | import scala.language.implicitConversions 9 | 10 | /** Parses arguments, resulting in a `T` in case of success. 11 | * 12 | * @tparam T: 13 | * success result type 14 | */ 15 | abstract class Parser[+T] extends ParserMethods[T] { 16 | 17 | import Parser.Step 18 | 19 | /** Intermediate result type. 20 | * 21 | * Used during parsing, while checking the arguments one after the other. 22 | * 23 | * If parsing succeeds, a `T` can be built from the [[D]] at the end of parsing. 24 | */ 25 | type D <: Tuple 26 | 27 | def stopAtFirstUnrecognized: Parser[T] = 28 | StopAtFirstUnrecognizedParser(this) 29 | def ignoreUnrecognized: Parser[T] = 30 | IgnoreUnrecognizedParser(this) 31 | 32 | def nameFormatter(f: Formatter[Name]): Parser[T] = 33 | ParserWithNameFormatter(this, f) 34 | 35 | /** Creates a [[Parser]] accepting help / usage arguments, out of this one. 36 | */ 37 | final def withHelp: Parser[WithHelp[T]] = { 38 | implicit val parser: Parser[T] = this 39 | val p = ParserWithNameFormatter(WithHelp.parser[T], defaultNameFormatter) 40 | if (defaultIgnoreUnrecognized) 41 | p.ignoreUnrecognized 42 | else if (defaultStopAtFirstUnrecognized) 43 | p.stopAtFirstUnrecognized 44 | else 45 | p 46 | } 47 | 48 | final def withFullHelp: Parser[WithFullHelp[T]] = { 49 | implicit val parser: Parser[T] = this 50 | val p0 = WithFullHelp.parser[T] 51 | val p = ParserWithNameFormatter(p0, defaultNameFormatter) 52 | if (defaultIgnoreUnrecognized) 53 | p.ignoreUnrecognized 54 | else if (defaultStopAtFirstUnrecognized) 55 | p.stopAtFirstUnrecognized 56 | else 57 | p 58 | } 59 | 60 | final def map[U](f: T => U): Parser[U] = 61 | MappedParser(this, f) 62 | 63 | def withDefaultOrigin(origin: String): Parser[T] 64 | } 65 | 66 | object Parser extends ParserCompanion with LowPriorityParserImplicits { 67 | 68 | /** Look for an implicit `Parser[T]` */ 69 | def apply[T](implicit parser: Parser[T]): Parser[T] = parser 70 | 71 | def nil: Parser[EmptyTuple] = 72 | NilParser 73 | 74 | implicit def option[T](implicit parser: Parser[T]): Parser[Option[T]] = 75 | OptionParser(parser) 76 | 77 | implicit def either[T](implicit parser: Parser[T]): Parser[Either[Error, T]] = 78 | EitherParser(parser) 79 | 80 | implicit def toParserOps[T <: Tuple](parser: Parser[T]): ParserOps[T] = 81 | new ParserOps(parser) 82 | 83 | } 84 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/help/HelpFormat.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.help 2 | 3 | import caseapp.core.Arg 4 | import caseapp.core.Scala3Helpers._ 5 | import caseapp.core.util.fansi 6 | import dataclass._ 7 | 8 | @data case class HelpFormat( 9 | progName: fansi.Attrs = fansi.Attrs.Empty, 10 | commandName: fansi.Attrs = fansi.Attrs.Empty, 11 | option: fansi.Attrs = fansi.Attrs.Empty, 12 | newLine: String = System.lineSeparator(), 13 | sortGroups: Option[Seq[String] => Seq[String]] = None, 14 | sortedGroups: Option[Seq[String]] = None, 15 | hiddenGroups: Option[Seq[String]] = None, 16 | sortCommandGroups: Option[Seq[String] => Seq[String]] = None, 17 | sortedCommandGroups: Option[Seq[String]] = None, 18 | hidden: fansi.Attrs = fansi.Attrs.Empty, 19 | terminalWidthOpt: Option[Int] = None, 20 | @since filterArgs: Option[Arg => Boolean] = None, 21 | @since filterArgsWhenShowHidden: Option[Arg => Boolean] = None, 22 | hiddenGroupsWhenShowHidden: Option[Seq[String]] = None, 23 | @since("2.1.0-M25") 24 | namesLimit: Option[Int] = None 25 | ) { 26 | private def sortValues[T]( 27 | sortGroups: Option[Seq[String] => Seq[String]], 28 | sortedGroups: Option[Seq[String]], 29 | elems: Seq[(String, T)], 30 | showHidden: Boolean 31 | ): Seq[(String, T)] = { 32 | val sortedGroups0 = sortGroups match { 33 | case None => 34 | sortedGroups match { 35 | case None => 36 | elems.sortBy(_._1) 37 | case Some(sortedGroups0) => 38 | val sorted = sortedGroups0.zipWithIndex.toMap 39 | elems.sortBy { case (group, _) => sorted.getOrElse(group, Int.MaxValue) } 40 | } 41 | case Some(sort) => 42 | val sorted = sort(elems.map(_._1)).zipWithIndex.toMap 43 | elems.sortBy { case (group, _) => sorted.getOrElse(group, Int.MaxValue) } 44 | } 45 | sortedGroups0.filter { case (group, _) => 46 | (if (showHidden) hiddenGroupsWhenShowHidden else hiddenGroups).forall(!_.contains(group)) 47 | } 48 | } 49 | 50 | def sortGroupValues[T](elems: Seq[(String, T)], showHidden: Boolean): Seq[(String, T)] = 51 | sortValues(sortGroups, sortedGroups, elems, showHidden) 52 | def sortCommandGroupValues[T](elems: Seq[(String, T)], showHidden: Boolean): Seq[(String, T)] = 53 | sortValues(sortCommandGroups, sortedCommandGroups, elems, showHidden) 54 | } 55 | 56 | object HelpFormat { 57 | def default(): HelpFormat = 58 | default(true) 59 | def default(ansiColors: Boolean): HelpFormat = 60 | if (ansiColors) 61 | HelpFormat() 62 | .withProgName(fansi.Bold.On) 63 | .withCommandName(fansi.Bold.On) 64 | .withOption(fansi.Color.Yellow) 65 | .withHidden(fansi.Color.DarkGray) 66 | else 67 | HelpFormat() 68 | } 69 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-2/caseapp/core/parser/Parser.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.Error 4 | import caseapp.core.help.{WithFullHelp, WithHelp} 5 | import shapeless.{HList, HNil} 6 | import caseapp.core.util.Formatter 7 | import caseapp.Name 8 | 9 | import scala.language.implicitConversions 10 | 11 | /** Parses arguments, resulting in a `T` in case of success. 12 | * 13 | * @tparam T: 14 | * success result type 15 | */ 16 | abstract class Parser[T] extends ParserMethods[T] { 17 | 18 | import Parser.Step 19 | 20 | /** Intermediate result type. 21 | * 22 | * Used during parsing, while checking the arguments one after the other. 23 | * 24 | * If parsing succeeds, a `T` can be built from the [[D]] at the end of parsing. 25 | */ 26 | type D 27 | 28 | def stopAtFirstUnrecognized: Parser.Aux[T, D] = 29 | StopAtFirstUnrecognizedParser(this) 30 | 31 | def ignoreUnrecognized: Parser.Aux[T, D] = 32 | IgnoreUnrecognizedParser(this) 33 | 34 | def nameFormatter(f: Formatter[Name]): Parser.Aux[T, D] = 35 | ParserWithNameFormatter(this, f) 36 | 37 | /** Creates a [[Parser]] accepting help / usage arguments, out of this one. 38 | */ 39 | final def withHelp: Parser[WithHelp[T]] = { 40 | implicit val parser: Parser.Aux[T, D] = this 41 | val p = ParserWithNameFormatter(Parser[WithHelp[T]], defaultNameFormatter) 42 | if (defaultIgnoreUnrecognized) 43 | p.ignoreUnrecognized 44 | else if (defaultStopAtFirstUnrecognized) 45 | p.stopAtFirstUnrecognized 46 | else 47 | p 48 | } 49 | 50 | final def withFullHelp: Parser[WithFullHelp[T]] = { 51 | implicit val parser: Parser.Aux[T, D] = this 52 | val p = ParserWithNameFormatter(Parser[WithFullHelp[T]], defaultNameFormatter) 53 | if (defaultIgnoreUnrecognized) 54 | p.ignoreUnrecognized 55 | else if (defaultStopAtFirstUnrecognized) 56 | p.stopAtFirstUnrecognized 57 | else 58 | p 59 | } 60 | 61 | final def map[U](f: T => U): Parser.Aux[U, D] = 62 | MappedParser(this, f) 63 | 64 | def withDefaultOrigin(origin: String): Parser.Aux[T, D] 65 | } 66 | 67 | object Parser extends ParserCompanion with LowPriorityParserImplicits { 68 | 69 | /** Look for an implicit `Parser[T]` */ 70 | def apply[T](implicit parser: Parser[T]): Aux[T, parser.D] = parser 71 | 72 | type Aux[T, D0] = Parser[T] { type D = D0 } 73 | 74 | /** An empty [[Parser]]. 75 | * 76 | * Can be made non empty by successively calling `add` on it. 77 | */ 78 | def nil: Parser.Aux[HNil, HNil] = 79 | NilParser 80 | 81 | implicit def option[T, D](implicit parser: Aux[T, D]): Parser.Aux[Option[T], D] = 82 | OptionParser(parser) 83 | 84 | implicit def either[T, D](implicit parser: Aux[T, D]): Parser.Aux[Either[Error, T], D] = 85 | EitherParser(parser) 86 | 87 | implicit def toParserOps[T <: HList, D <: HList](parser: Aux[T, D]): ParserOps[T, D] = 88 | new ParserOps(parser) 89 | 90 | } 91 | -------------------------------------------------------------------------------- /cats/shared/src/test/scala/caseapp/catseffect/CatsTests.scala: -------------------------------------------------------------------------------- 1 | package caseapp.catseffect 2 | 3 | import cats.effect._ 4 | import cats.effect.unsafe.implicits.global 5 | import cats.data.NonEmptyList 6 | import caseapp._ 7 | import caseapp.core.help.Help 8 | import caseapp.core.Error 9 | import utest._ 10 | 11 | sealed trait RecordedApp { 12 | 13 | val stdoutBuff: Ref[IO, List[String]] = Ref.unsafe(List.empty) 14 | val stderrBuff: Ref[IO, List[String]] = Ref.unsafe(List.empty) 15 | 16 | def run(args: List[String]): IO[ExitCode] 17 | } 18 | 19 | private class RecordedIOCaseApp[T](implicit parser0: Parser[T], messages: Help[T]) 20 | extends IOCaseApp[T]()(parser0, messages) with RecordedApp { 21 | 22 | override def error(message: Error): IO[ExitCode] = 23 | stderrBuff.update(message.message :: _) 24 | .as(ExitCode.Error) 25 | 26 | override def println(x: String): IO[Unit] = 27 | stdoutBuff.update(x :: _) 28 | 29 | override def run(options: T, remainingArgs: RemainingArgs): IO[ExitCode] = 30 | println(s"run: $options").as(ExitCode.Success) 31 | } 32 | 33 | object CatsTests extends TestSuite { 34 | 35 | import Definitions._ 36 | 37 | private def testCaseStdout(args: List[String], expected: String) = 38 | testRunFuture( 39 | new RecordedIOCaseApp[FewArgs](), 40 | args, 41 | expectedStdout = List(expected), 42 | expectedStderr = List.empty 43 | ) 44 | 45 | private def testCaseStderr(args: List[String], expected: String) = 46 | testRunFuture( 47 | new RecordedIOCaseApp[FewArgs](), 48 | args, 49 | expectedStdout = List.empty, 50 | expectedStderr = List(expected) 51 | ) 52 | 53 | private def testRunFuture( 54 | app: RecordedApp, 55 | args: List[String], 56 | expectedStdout: List[String], 57 | expectedStderr: List[String] 58 | ) = 59 | app.run(args) 60 | .flatMap { _ => 61 | for { 62 | stdoutRes <- app.stdoutBuff.get 63 | stderrRes <- app.stderrBuff.get 64 | } yield assert(stdoutRes == expectedStdout, stderrRes == expectedStderr) 65 | } 66 | .unsafeToFuture() 67 | 68 | override def tests: Tests = Tests { 69 | test("IOCaseApp") { 70 | test("output usage") { 71 | testCaseStdout(List("--usage"), Help[FewArgs].withHelp.usage) 72 | } 73 | test("output help") { 74 | testCaseStdout(List("--help"), Help[FewArgs].withHelp.help) 75 | } 76 | test("parse error") { 77 | testCaseStderr(List("--invalid"), "Unrecognized argument: --invalid") 78 | } 79 | test("run") { 80 | testCaseStdout(List("--value", "foo", "--num-foo", "42"), "run: FewArgs(foo,42)") 81 | } 82 | } 83 | 84 | test("parse nonEmptyList args") { 85 | val res = 86 | Parser[WithNonEmptyList].parse(Seq("--nel", "2", "--nel", "5", "extra")) 87 | val expectedRes = 88 | Right((WithNonEmptyList(nel = NonEmptyList.of("2", "5")), Seq("extra"))) 89 | assert(res == expectedRes) 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/app/ProfileFileUpdater.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.app 2 | 3 | // from https://github.com/VirtusLab/scala-cli/blob/eced0b35c769eca58ae6f1b1a3be0f29a8700859/modules/cli/src/main/scala/scala/cli/internal/ProfileFileUpdater.scala 4 | 5 | import caseapp.core.app.nio._ 6 | 7 | // initially adapted from https://github.com/coursier/coursier/blob/d9a0fcc1af4876bec7f19a18f2c93d808e06df8d/modules/env/src/main/scala/coursier/env/ProfileUpdater.scala#L44-L137 8 | 9 | object ProfileFileUpdater { 10 | 11 | private def startEndIndices(start: String, end: String, content: String): Option[(Int, Int)] = { 12 | val startIdx = content.indexOf(start) 13 | if (startIdx >= 0) { 14 | val endIdx = content.indexOf(end, startIdx + 1) 15 | if (endIdx >= 0) 16 | Some(startIdx, endIdx + end.length) 17 | else 18 | None 19 | } 20 | else 21 | None 22 | } 23 | 24 | def addToProfileFile( 25 | file: Path, 26 | title: String, 27 | addition: String 28 | ): Boolean = { 29 | 30 | def updated(content: String): Option[String] = { 31 | val start = s"# >>> $title >>>\n" 32 | val endStr = s"# <<< $title <<<\n" 33 | val withTags = "\n" + 34 | start + 35 | addition.stripSuffix("\n") + "\n" + endStr 36 | if (content.contains(withTags)) 37 | None 38 | else 39 | Some { 40 | startEndIndices(start, endStr, content) match { 41 | case None => 42 | content + withTags 43 | case Some((startIdx, endIdx)) => 44 | content.take(startIdx) + 45 | withTags + 46 | content.drop(endIdx) 47 | } 48 | } 49 | } 50 | 51 | var updatedSomething = false 52 | val contentOpt = Some(file) 53 | .filter(FileOps.exists(_)) 54 | .map(f => FileOps.readFile(f)) 55 | for (updatedContent <- updated(contentOpt.getOrElse(""))) { 56 | Option(file.getParent).map(FileOps.createDirectories(_)) 57 | FileOps.writeFile(file, updatedContent) 58 | updatedSomething = true 59 | } 60 | updatedSomething 61 | } 62 | 63 | def removeFromProfileFile( 64 | file: Path, 65 | title: String 66 | ): Boolean = { 67 | 68 | def updated(content: String): Option[String] = { 69 | val start = s"# >>> $title >>>\n" 70 | val end = s"# <<< $title <<<\n" 71 | startEndIndices(start, end, content).map { 72 | case (startIdx, endIdx) => 73 | content.take(startIdx).stripSuffix("\n") + 74 | content.drop(endIdx) 75 | } 76 | } 77 | 78 | var updatedSomething = false 79 | val contentOpt = Some(file) 80 | .filter(FileOps.exists(_)) 81 | .map(f => FileOps.readFile(f)) 82 | for (updatedContent <- updated(contentOpt.getOrElse(""))) { 83 | Option(file.getParent).map(FileOps.createDirectories(_)) 84 | FileOps.writeFile(file, updatedContent) 85 | updatedSomething = true 86 | } 87 | updatedSomething 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /cats2/shared/src/test/scala/caseapp/catseffect/CatsTests.scala: -------------------------------------------------------------------------------- 1 | package caseapp.catseffect 2 | 3 | import cats.effect._ 4 | import cats.effect.concurrent.Ref 5 | import cats.implicits._ 6 | import cats.data.NonEmptyList 7 | import caseapp._ 8 | import caseapp.core.help.Help 9 | import caseapp.core.Error 10 | import utest._ 11 | 12 | sealed trait RecordedApp { 13 | 14 | val stdoutBuff: Ref[IO, List[String]] = Ref.unsafe(List.empty) 15 | val stderrBuff: Ref[IO, List[String]] = Ref.unsafe(List.empty) 16 | 17 | def run(args: List[String]): IO[ExitCode] 18 | } 19 | 20 | private class RecordedIOCaseApp[T](implicit parser0: Parser[T], messages: Help[T]) 21 | extends IOCaseApp[T]()(parser0, messages) with RecordedApp { 22 | 23 | override def error(message: Error): IO[ExitCode] = 24 | stderrBuff.update(message.message :: _) 25 | .as(ExitCode.Error) 26 | 27 | override def println(x: String): IO[Unit] = 28 | stdoutBuff.update(x :: _) 29 | 30 | override def run(options: T, remainingArgs: RemainingArgs): IO[ExitCode] = 31 | println(s"run: $options").as(ExitCode.Success) 32 | } 33 | 34 | object CatsTests extends TestSuite { 35 | 36 | import Definitions._ 37 | 38 | private def testCaseStdout(args: List[String], expected: String) = 39 | testRunFuture( 40 | new RecordedIOCaseApp[FewArgs](), 41 | args, 42 | expectedStdout = List(expected), 43 | expectedStderr = List.empty 44 | ) 45 | 46 | private def testCaseStderr(args: List[String], expected: String) = 47 | testRunFuture( 48 | new RecordedIOCaseApp[FewArgs](), 49 | args, 50 | expectedStdout = List.empty, 51 | expectedStderr = List(expected) 52 | ) 53 | 54 | private def testRunFuture( 55 | app: RecordedApp, 56 | args: List[String], 57 | expectedStdout: List[String], 58 | expectedStderr: List[String] 59 | ) = 60 | app.run(args) 61 | .flatMap { _ => 62 | for { 63 | stdoutRes <- app.stdoutBuff.get 64 | stderrRes <- app.stderrBuff.get 65 | } yield assert(stdoutRes == expectedStdout, stderrRes == expectedStderr) 66 | } 67 | .unsafeToFuture() 68 | 69 | override def tests: Tests = Tests { 70 | test("IOCaseApp") { 71 | test("output usage") { 72 | testCaseStdout(List("--usage"), Help[FewArgs].withHelp.usage) 73 | } 74 | test("output help") { 75 | testCaseStdout(List("--help"), Help[FewArgs].withHelp.help) 76 | } 77 | test("parse error") { 78 | testCaseStderr(List("--invalid"), "Unrecognized argument: --invalid") 79 | } 80 | test("run") { 81 | testCaseStdout(List("--value", "foo", "--num-foo", "42"), "run: FewArgs(foo,42)") 82 | } 83 | } 84 | 85 | test("parse nonEmptyList args") { 86 | val res = 87 | Parser[WithNonEmptyList].parse(Seq("--nel", "2", "--nel", "5", "extra")) 88 | val expectedRes = 89 | Right((WithNonEmptyList(nel = NonEmptyList.of("2", "5")), Seq("extra"))) 90 | assert(res == expectedRes) 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | tags: 7 | - "v*" 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | SCALA_VERSION: ["2.12.20", "2.13.16", "3.3.6"] 17 | steps: 18 | - uses: actions/checkout@v6 19 | with: 20 | fetch-depth: 0 21 | submodules: true 22 | - uses: coursier/cache-action@v7.0 23 | - uses: coursier/setup-action@v1.3 24 | with: 25 | jvm: 21 26 | - name: Test 27 | run: ./mill -i '__[${{ matrix.SCALA_VERSION }}].test' 28 | 29 | bin-compat: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v6 33 | with: 34 | fetch-depth: 0 35 | submodules: true 36 | - uses: coursier/cache-action@v7.0 37 | - uses: coursier/setup-action@v1.3 38 | with: 39 | jvm: 21 40 | - name: Check 41 | run: ./mill -i __.mimaReportBinaryIssues 42 | 43 | format: 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v6 47 | with: 48 | fetch-depth: 0 49 | submodules: true 50 | - uses: coursier/cache-action@v7.0 51 | - uses: coursier/setup-action@v1.3 52 | with: 53 | jvm: 21 54 | apps: scalafmt:3.7.14 55 | - run: scalafmt --check 56 | 57 | doc: 58 | runs-on: ubuntu-latest 59 | steps: 60 | - uses: actions/checkout@v6 61 | with: 62 | fetch-depth: 0 63 | submodules: true 64 | - uses: coursier/cache-action@v7.0 65 | - uses: coursier/setup-action@v1.3 66 | with: 67 | jvm: 21 68 | - run: | 69 | .github/scripts/setup-mkdocs.sh && \ 70 | ./mill -i docs.mkdocsBuild 71 | 72 | publish: 73 | if: github.event_name == 'push' 74 | runs-on: ubuntu-latest 75 | steps: 76 | - uses: actions/checkout@v6 77 | with: 78 | fetch-depth: 0 79 | submodules: true 80 | - uses: coursier/cache-action@v7.0 81 | - uses: coursier/setup-action@v1.3 82 | with: 83 | jvm: 21 84 | - name: Release 85 | run: ./mill -i mill.scalalib.SonatypeCentralPublishModule/ 86 | env: 87 | MILL_PGP_SECRET_BASE64: ${{ secrets.PUBLISH_SECRET_KEY }} 88 | MILL_PGP_PASSPHRASE: ${{ secrets.PUBLISH_SECRET_KEY_PASSWORD }} 89 | MILL_SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 90 | MILL_SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 91 | 92 | update-website: 93 | if: startsWith(github.ref, 'refs/tags/v') 94 | runs-on: ubuntu-latest 95 | steps: 96 | - uses: actions/checkout@v6 97 | with: 98 | fetch-depth: 0 99 | submodules: true 100 | - uses: coursier/cache-action@v7.0 101 | - uses: coursier/setup-action@v1.3 102 | with: 103 | jvm: 21 104 | - run: | 105 | .github/scripts/setup-mkdocs.sh && \ 106 | ./mill -i docs.mkdocsGhDeploy 107 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-3/caseapp/core/help/HelpCompanion.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.help 2 | 3 | import caseapp.core.parser.Parser 4 | import caseapp.core.parser.LowPriorityParserImplicits 5 | import caseapp.core.parser.LowPriorityParserImplicits.ofOption 6 | import caseapp.core.util.CaseUtil 7 | 8 | import scala.compiletime.* 9 | import scala.deriving.* 10 | import scala.quoted.{given, *} 11 | 12 | object HelpCompanion { 13 | inline def deriveHelp[T]: Help[T] = 14 | ${ deriveHelpImpl } 15 | def deriveHelpImpl[T](using q: Quotes, t: Type[T]): Expr[Help[T]] = { 16 | import quotes.reflect.* 17 | val sym = TypeTree.of[T].symbol 18 | val parserExpr = Implicits.search(TypeRepr.of[Parser[T]]) match { 19 | case iss: ImplicitSearchSuccess => 20 | iss.tree.asExpr.asExprOf[Parser[T]] 21 | case isf: ImplicitSearchFailure => 22 | throw new Exception(s"No given ${Type.show[Parser[T]]} instance found") 23 | } 24 | val appName = sym.annotations 25 | .find(_.tpe =:= TypeRepr.of[caseapp.AppName]) 26 | .collect { 27 | case Apply(_, List(arg)) => 28 | arg.asExprOf[String] 29 | } 30 | .getOrElse { 31 | Expr(LowPriorityParserImplicits.shortName[T].stripSuffix("Options")) 32 | } 33 | val appVersion = sym.annotations 34 | .find(_.tpe =:= TypeRepr.of[caseapp.AppVersion]) 35 | .collect { 36 | case Apply(_, List(arg)) => 37 | arg.asExprOf[String] 38 | } 39 | .getOrElse(Expr("")) 40 | val progName = sym.annotations 41 | .find(_.tpe =:= TypeRepr.of[caseapp.ProgName]) 42 | .collect { 43 | case Apply(_, List(arg)) => 44 | arg.asExprOf[String] 45 | } 46 | val argsName = sym.annotations 47 | .find(_.tpe =:= TypeRepr.of[caseapp.ArgsName]) 48 | .collect { 49 | case Apply(_, List(arg)) => 50 | arg.asExprOf[String] 51 | } 52 | val helpMessage = sym.annotations 53 | .find(_.tpe =:= TypeRepr.of[caseapp.HelpMessage]) 54 | .collect { 55 | case Apply(_, List(arg, argMd, argDetailed)) => 56 | '{ 57 | caseapp.HelpMessage( 58 | ${ arg.asExprOf[String] }, 59 | ${ argMd.asExprOf[String] }, 60 | ${ argDetailed.asExprOf[String] } 61 | ) 62 | } 63 | } 64 | '{ 65 | val parser = $parserExpr 66 | val appName0 = $appName 67 | val progName0 = ${ Expr.ofOption(progName) }.getOrElse { 68 | CaseUtil.pascalCaseSplit(appName0.toList).map(_.toLowerCase).mkString("-") 69 | } 70 | Help( 71 | args = parser.args, 72 | appName = appName0, 73 | appVersion = $appVersion, 74 | progName = progName0, 75 | argsNameOption = ${ Expr.ofOption(argsName) }, 76 | optionsDesc = Help.DefaultOptionsDesc, 77 | nameFormatter = parser.defaultNameFormatter, 78 | helpMessage = ${ Expr.ofOption(helpMessage) } 79 | ) 80 | } 81 | } 82 | } 83 | 84 | abstract class HelpCompanion { 85 | inline given derive[T]: Help[T] = 86 | HelpCompanion.deriveHelp[T] 87 | } 88 | -------------------------------------------------------------------------------- /docs/pages/setup.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | Depend on case-app via `com.github.alexarchambault::case-app:@VERSION@`. 4 | The latest version is [![Maven Central](https://img.shields.io/maven-central/v/com.github.alexarchambault/case-app_3.svg)](https://maven-badges.herokuapp.com/maven-central/com.github.alexarchambault/case-app_3). 5 | 6 | ## JVM 7 | 8 | ```scala mdoc:invisible 9 | val isSnapshot = "@VERSION@".endsWith("SNAPSHOT") 10 | ``` 11 | 12 | From [Mill](https://github.com/com-lihaoyi/Mill): 13 | ```scala mdoc:passthrough 14 | def millMaybeAddSonatypeSnapshots() = 15 | if (isSnapshot) 16 | println( 17 | """def repositoriesTask = T { 18 | | super.repositoriesTask() ++ Seq( 19 | | coursier.Repositories.sonatype("snapshots") 20 | | ) 21 | |}""".stripMargin 22 | ) 23 | println("```scala") 24 | millMaybeAddSonatypeSnapshots() 25 | println( 26 | """def mvnDeps = Agg( 27 | | mvn"com.github.alexarchambault::case-app:@VERSION@" 28 | |)""".stripMargin 29 | ) 30 | println("```") 31 | ``` 32 | 33 | From [Scala CLI](https://github.com/VirtusLab/scala-cli): 34 | ```scala mdoc:passthrough 35 | def scalaCliMaybeAddSonatypeSnapshots() = 36 | if (isSnapshot) 37 | println("//> using repository sonatype:snapshots") 38 | println("```scala") 39 | scalaCliMaybeAddSonatypeSnapshots() 40 | println("//> using dep com.github.alexarchambault::case-app:@VERSION@") 41 | println("```") 42 | ``` 43 | 44 | From [sbt](https://github.com/sbt/sbt): 45 | ```scala mdoc:passthrough 46 | def sbtMaybeAddSonatypeSnapshots() = 47 | if (isSnapshot) 48 | println("""resolvers ++= Resolver.sonatypeOssRepos("snapshots")""") 49 | println("```scala") 50 | sbtMaybeAddSonatypeSnapshots() 51 | println("""libraryDependencies += "com.github.alexarchambault" %% "case-app" % "@VERSION@"""") 52 | println("```") 53 | ``` 54 | 55 | ## Scala.js and Scala Native 56 | 57 | Scala.js and Scala Native dependencies need to be marked as platform-specific, usually 58 | [with an extra `:` or `%`](https://youforgotapercentagesignoracolon.com). 59 | 60 | From [Mill](https://github.com/com-lihaoyi/Mill): 61 | ```scala mdoc:passthrough 62 | println("```scala") 63 | millMaybeAddSonatypeSnapshots() 64 | println( 65 | """def mvnDeps = Agg( 66 | | mvn"com.github.alexarchambault::case-app::@VERSION@" 67 | |)""".stripMargin 68 | ) 69 | println("```") 70 | ``` 71 | 72 | From [Scala CLI](https://github.com/VirtusLab/scala-cli): 73 | ```scala mdoc:passthrough 74 | println("```scala") 75 | scalaCliMaybeAddSonatypeSnapshots() 76 | println("//> using dep com.github.alexarchambault::case-app::@VERSION@") 77 | println("```") 78 | ``` 79 | 80 | From [sbt](https://github.com/sbt/sbt): 81 | ```scala mdoc:passthrough 82 | println("```scala") 83 | sbtMaybeAddSonatypeSnapshots() 84 | println("""libraryDependencies += "com.github.alexarchambault" %%% "case-app" % "@VERSION@"""") 85 | println("```") 86 | ``` 87 | 88 | ## Imports 89 | 90 | Most case-app classes that are of relevance for end-users have aliases in the 91 | `caseapp` package object. Importing its content is usually fine to use most 92 | case-app features: 93 | ```scala mdoc:reset 94 | import caseapp._ 95 | ``` 96 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/parser/StandardArgument.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.core.argparser.{ArgParser, Consumed} 4 | import caseapp.core.{Arg, Error} 5 | import caseapp.core.util.NameOps.toNameOps 6 | import dataclass.data 7 | import caseapp.core.util.Formatter 8 | import caseapp.Name 9 | 10 | @data case class StandardArgument[H]( 11 | arg: Arg, 12 | argParser: ArgParser[H], 13 | default: () => Option[H] // FIXME Couldn't this be Option[() => H]? 14 | ) extends Argument[H] { 15 | 16 | import StandardArgument._ 17 | 18 | def withDefaultOrigin(origin: String): Argument[H] = 19 | this.withArg(arg.withDefaultOrigin(origin)) 20 | 21 | def init: Option[H] = 22 | None 23 | 24 | def step( 25 | args: List[String], 26 | index: Int, 27 | d: Option[H], 28 | nameFormatter: Formatter[Name] 29 | ): Either[(Error, List[String]), Option[(Option[H], List[String])]] = 30 | args match { 31 | case Nil => 32 | Right(None) 33 | 34 | case firstArg :: rem => 35 | val matchedOpt = arg.names 36 | .iterator 37 | .map(n => n -> n(firstArg, nameFormatter)) 38 | .collectFirst { 39 | case (n, Right(valueOpt)) => n -> valueOpt 40 | } 41 | 42 | matchedOpt match { 43 | case Some((name, valueOpt)) => 44 | val (res, rem0) = valueOpt match { 45 | case Some(value) => 46 | val res0 = argParser(d, index, 1, value) 47 | .map(h => Some(Some(h))) 48 | (res0, rem) 49 | case None => 50 | rem match { 51 | case Nil => 52 | val res0 = argParser(d, index) 53 | .map(h => Some(Some(h))) 54 | (res0, Nil) 55 | case th :: tRem => 56 | val (Consumed(usedArg), res) = argParser.optional(d, index, 2, th) 57 | val res0 = res.map(h => Some(Some(h))) 58 | (res0, if (usedArg) tRem else rem) 59 | } 60 | } 61 | 62 | res 63 | .left 64 | .map { err => 65 | (Error.ParsingArgument(name, err, nameFormatter), rem0) 66 | } 67 | .map(_.map((_, rem0))) 68 | 69 | case None => 70 | Right(None) 71 | } 72 | } 73 | 74 | def get(d: Option[H], nameFormatter: Formatter[Name]): Either[Error, H] = 75 | d.orElse(default()).toRight { 76 | Error.RequiredOptionNotSpecified( 77 | arg.name.option(nameFormatter), 78 | arg.extraNames.map(_.option(nameFormatter)) 79 | ) 80 | } 81 | 82 | val args: Seq[Arg] = 83 | Seq(arg) 84 | 85 | } 86 | 87 | object StandardArgument { 88 | def apply[H: ArgParser](arg: Arg): StandardArgument[H] = 89 | StandardArgument[H](arg, ArgParser[H], () => None) 90 | 91 | implicit class StandardArgumentWithOps[H](private val standardArg: StandardArgument[H]) 92 | extends AnyVal { 93 | def withArg(arg: Arg): StandardArgument[H] = 94 | standardArg.copy(arg = arg) 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /tests/shared/src/test/scala/caseapp/HelpDefinitions.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | 3 | object HelpDefinitions { 4 | case class FirstOptions( 5 | @ExtraName("f") 6 | @Tag("foo") 7 | foo: String = "", 8 | bar: Int = 0 9 | ) 10 | 11 | case class SecondOptions( 12 | fooh: String = "", 13 | baz: Int = 0 14 | ) 15 | 16 | @HelpMessage("Third help message") 17 | case class ThirdOptions( 18 | third: Int = 0 19 | ) 20 | 21 | case class FourthOptions( 22 | @ExtraName("fourOption") 23 | @ExtraName("f") 24 | @ExtraName("four") 25 | @ExtraName("fOpt") 26 | 27 | fourth: Int = 0 28 | ) 29 | 30 | object First extends Command[FirstOptions] { 31 | def run(options: FirstOptions, args: RemainingArgs) = ??? 32 | } 33 | object Second extends Command[SecondOptions] { 34 | def run(options: SecondOptions, args: RemainingArgs) = ??? 35 | } 36 | object Third extends Command[ThirdOptions] { 37 | def run(options: ThirdOptions, args: RemainingArgs) = ??? 38 | } 39 | 40 | object Fourth extends Command[FourthOptions] { 41 | def run(options: FourthOptions, args: RemainingArgs) = ??? 42 | } 43 | 44 | @HelpMessage("Example help message") 45 | final case class GroupedOptions( 46 | @Group("Something") 47 | foo: String, 48 | @Group("Bb") 49 | bar: Int, 50 | @Group("Something") 51 | other: Double, 52 | @Group("Bb") 53 | something: Boolean 54 | ) 55 | 56 | @HelpMessage("Example help message") 57 | final case class HiddenGroupOptions( 58 | @Group("Something") 59 | foo: String, 60 | @Group("Bb") 61 | @Hidden 62 | bar: Int, 63 | @Group("Something") 64 | other: Double, 65 | @Group("Bb") 66 | @Hidden 67 | something: Boolean 68 | ) 69 | 70 | object CommandGroups { 71 | object First extends Command[FirstOptions] { 72 | override def group = "Aa" 73 | def run(options: FirstOptions, args: RemainingArgs) = ??? 74 | } 75 | object Second extends Command[SecondOptions] { 76 | override def group = "Bb" 77 | def run(options: SecondOptions, args: RemainingArgs) = ??? 78 | } 79 | object Third extends Command[ThirdOptions] { 80 | override def group = "Aa" 81 | def run(options: ThirdOptions, args: RemainingArgs) = ??? 82 | } 83 | } 84 | 85 | object HiddenCommands { 86 | object First extends Command[FirstOptions] { 87 | override def group = "Aa" 88 | override def hidden = true 89 | def run(options: FirstOptions, args: RemainingArgs) = ??? 90 | } 91 | object Second extends Command[SecondOptions] { 92 | override def group = "Bb" 93 | def run(options: SecondOptions, args: RemainingArgs) = ??? 94 | } 95 | object Third extends Command[ThirdOptions] { 96 | override def group = "Aa" 97 | def run(options: ThirdOptions, args: RemainingArgs) = ??? 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /util/shared/src/main/scala-2/caseapp/util/AnnotationListMacros.scala: -------------------------------------------------------------------------------- 1 | package caseapp.util 2 | 3 | import shapeless._ 4 | 5 | import scala.reflect.macros.whitebox 6 | 7 | class AnnotationListMacros(val c: whitebox.Context) extends CaseClassMacros { 8 | import c.universe._ 9 | 10 | def consTpe = typeOf[scala.::[_]].typeConstructor 11 | def nilTpe = typeOf[Nil.type] 12 | 13 | // FIXME Most of the content of this method is cut-n-pasted from generic.scala 14 | def construct(tpe: Type): List[Tree] => Tree = { 15 | // FIXME Cut-n-pasted from generic.scala 16 | val sym = tpe.typeSymbol 17 | val isCaseClass = sym.asClass.isCaseClass 18 | def hasNonGenericCompanionMember(name: String): Boolean = { 19 | val mSym = sym.companion.typeSignature.member(TermName(name)) 20 | mSym != NoSymbol && !isNonGeneric(mSym) 21 | } 22 | 23 | if (isCaseClass || hasNonGenericCompanionMember("apply")) 24 | args => q"${companionRef(tpe)}(..$args)" 25 | else 26 | args => q"new $tpe(..$args)" 27 | } 28 | 29 | def materializeAnnotationList[A: WeakTypeTag, T: WeakTypeTag, Out: WeakTypeTag]: Tree = { 30 | val annTpe = weakTypeOf[A] 31 | 32 | if (!isProduct(annTpe)) 33 | abort(s"$annTpe is not a case class-like type") 34 | 35 | val construct0 = construct(annTpe) 36 | 37 | val tpe = weakTypeOf[T] 38 | 39 | val annTreeLists = 40 | if (isProduct(tpe)) { 41 | val constructorSyms = tpe 42 | .member(termNames.CONSTRUCTOR) 43 | .asMethod 44 | .paramLists 45 | .flatten 46 | .map(sym => nameAsString(sym.name) -> sym) 47 | .toMap 48 | 49 | val fields = fieldsOf(tpe) 50 | // Looking at these unveils extra annotations below 51 | // (what we call a "side effect") 52 | fields.map { case (name, _) => 53 | tpe.member(name).annotations 54 | } 55 | 56 | fields.map { case (name, _) => 57 | val paramConstrSym = constructorSyms(nameAsString(name)) 58 | 59 | paramConstrSym.annotations.collect { 60 | case ann if ann.tree.tpe =:= annTpe => construct0(ann.tree.children.tail) 61 | } 62 | } 63 | } 64 | else if (isCoproduct(tpe)) 65 | ctorsOf(tpe).map { cTpe => 66 | cTpe.typeSymbol.annotations.collect { 67 | case ann if ann.tree.tpe =:= annTpe => construct0(ann.tree.children.tail) 68 | } 69 | } 70 | else 71 | abort(s"$tpe is not case class like or the root of a sealed family of types") 72 | 73 | val wrapTpeTrees = annTreeLists.map { 74 | case Nil => nilTpe -> q"_root_.scala.Nil" 75 | case l => 76 | def listTree(trees: List[Tree]): Tree = { 77 | import scala.:: 78 | trees match { 79 | case Nil => q"_root_.scala.Nil" 80 | case h :: t => q"_root_.scala.::($h, ${listTree(t)})" 81 | } 82 | } 83 | 84 | appliedType(consTpe, annTpe) -> listTree(l) 85 | } 86 | 87 | val outTpe = mkHListTpe(wrapTpeTrees.map { case (aTpe, _) => aTpe }) 88 | val outTree = wrapTpeTrees.foldRight(q"_root_.shapeless.HNil": Tree) { 89 | case ((_, bound), acc) => pq"_root_.shapeless.::($bound, $acc)" 90 | } 91 | 92 | q"_root_.caseapp.util.AnnotationList.instance[$annTpe, $tpe, $outTpe]($outTree)" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /cats/shared/src/main/scala/caseapp/catseffect/IOCaseApp.scala: -------------------------------------------------------------------------------- 1 | package caseapp.catseffect 2 | 3 | import caseapp.core.Error 4 | import caseapp.core.help.{Help, WithHelp} 5 | import caseapp.core.parser.Parser 6 | import caseapp.core.RemainingArgs 7 | import caseapp.Name 8 | import caseapp.core.util.Formatter 9 | import cats.effect.{ExitCode, IO, IOApp} 10 | 11 | abstract class IOCaseApp[T](implicit val parser0: Parser[T], val messages: Help[T]) extends IOApp { 12 | 13 | def parser: Parser[T] = { 14 | val p = parser0.nameFormatter(nameFormatter) 15 | if (ignoreUnrecognized) 16 | p.ignoreUnrecognized 17 | else if (stopAtFirstUnrecognized) 18 | p.stopAtFirstUnrecognized 19 | else 20 | p 21 | } 22 | 23 | def run(options: T, remainingArgs: RemainingArgs): IO[ExitCode] 24 | 25 | def error(message: Error): IO[ExitCode] = 26 | IO(Console.err.println(message.message)) 27 | .as(ExitCode.Error) 28 | 29 | def helpAsked: IO[ExitCode] = 30 | println(messages.withHelp.help) 31 | .as(ExitCode.Success) 32 | 33 | def usageAsked: IO[ExitCode] = 34 | println(messages.withHelp.usage) 35 | .as(ExitCode.Success) 36 | 37 | def println(x: String): IO[Unit] = 38 | IO(Console.println(x)) 39 | 40 | /** Arguments are expanded then parsed. By default, argument expansion is the identity function. 41 | * Overriding this method allows plugging in an arbitrary argument expansion logic. 42 | * 43 | * One such expansion logic involves replacing each argument of the form '@' with the 44 | * contents of that file where each line in the file becomes a distinct argument. To enable this 45 | * behavior, override this method as shown below. 46 | * 47 | * @example 48 | * {{{ 49 | * import caseapp.core.parser.PlatformArgsExpander 50 | * override def expandArgs(args: List[String]): List[String] 51 | * = PlatformArgsExpander.expand(args) 52 | * }}} 53 | * 54 | * @param args 55 | * @return 56 | */ 57 | def expandArgs(args: List[String]): List[String] = args 58 | 59 | /** Whether to stop parsing at the first unrecognized argument. 60 | * 61 | * That is, stop parsing at the first non option (not starting with "-"), or the first 62 | * unrecognized option. The unparsed arguments are put in the `args` argument of `run`. 63 | */ 64 | def stopAtFirstUnrecognized: Boolean = 65 | false 66 | 67 | /** Whether to ignore unrecognized arguments. 68 | * 69 | * That is, if there are unrecognized arguments, the parsing still succeeds. The unparsed 70 | * arguments are put in the `args` argument of `run`. 71 | */ 72 | def ignoreUnrecognized: Boolean = 73 | false 74 | 75 | def nameFormatter: Formatter[Name] = 76 | Formatter.DefaultNameFormatter 77 | 78 | override def run(args: List[String]): IO[ExitCode] = 79 | parser.withHelp.detailedParse( 80 | expandArgs(args), 81 | stopAtFirstUnrecognized, 82 | ignoreUnrecognized 83 | ) match { 84 | case Left(err) => error(err) 85 | case Right((WithHelp(_, true, _), _)) => helpAsked 86 | case Right((WithHelp(true, _, _), _)) => usageAsked 87 | case Right((WithHelp(_, _, Left(err)), _)) => error(err) 88 | case Right((WithHelp(_, _, Right(t)), remainingArgs)) => run(t, remainingArgs) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/complete/Completer.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.complete 2 | 3 | import caseapp.core.help.{WithFullHelp, WithHelp} 4 | import caseapp.core.{Arg, RemainingArgs} 5 | 6 | trait Completer[-T] { self => 7 | def optionName(prefix: String, state: Option[T], args: RemainingArgs): List[CompletionItem] 8 | def optionValue( 9 | arg: Arg, 10 | prefix: String, 11 | state: Option[T], 12 | args: RemainingArgs 13 | ): List[CompletionItem] 14 | def argument(prefix: String, state: Option[T], args: RemainingArgs): List[CompletionItem] 15 | 16 | def postDoubleDash(state: Option[T], args: RemainingArgs): Option[Completer[T]] = 17 | None 18 | 19 | final def contramapOpt[U](f: U => Option[T]): Completer[U] = 20 | Completer.Mapped(this, f) 21 | final def withHelp: Completer[WithHelp[T]] = 22 | contramapOpt(_.baseOrError.toOption) 23 | final def withFullHelp: Completer[WithFullHelp[T]] = 24 | contramapOpt(_.withHelp.baseOrError.toOption) 25 | } 26 | 27 | object Completer { 28 | 29 | implicit class CompleterOps[T](private val completer: Completer[T]) extends AnyVal { 30 | def completeOptionValue(f: ( 31 | Arg, 32 | String, 33 | Option[T], 34 | RemainingArgs 35 | ) => Option[List[CompletionItem]]): Completer[T] = 36 | WithOptionValue(completer, f) 37 | } 38 | 39 | class DefaultTo[-T](default: Completer[T]) extends Completer[T] { 40 | override def optionName( 41 | prefix: String, 42 | state: Option[T], 43 | args: RemainingArgs 44 | ): List[CompletionItem] = 45 | default.optionName(prefix, state, args) 46 | override def optionValue( 47 | arg: Arg, 48 | prefix: String, 49 | state: Option[T], 50 | args: RemainingArgs 51 | ): List[CompletionItem] = 52 | default.optionValue(arg, prefix, state, args) 53 | override def argument( 54 | prefix: String, 55 | state: Option[T], 56 | args: RemainingArgs 57 | ): List[CompletionItem] = 58 | default.argument(prefix, state, args) 59 | override def postDoubleDash(state: Option[T], args: RemainingArgs): Option[Completer[T]] = 60 | default.postDoubleDash(state, args) 61 | 62 | override def toString(): String = 63 | s"Completer.DefaultTo($default)" 64 | } 65 | 66 | private final case class WithOptionValue[T]( 67 | self: Completer[T], 68 | f: (Arg, String, Option[T], RemainingArgs) => Option[List[CompletionItem]] 69 | ) extends DefaultTo[T](self) { 70 | override def optionValue( 71 | arg: Arg, 72 | prefix: String, 73 | state: Option[T], 74 | args: RemainingArgs 75 | ): List[CompletionItem] = 76 | f(arg, prefix, state, args).getOrElse { 77 | super.optionValue(arg, prefix, state, args) 78 | } 79 | } 80 | 81 | private final case class Mapped[T, U](self: Completer[T], f: U => Option[T]) 82 | extends Completer[U] { 83 | def optionName(prefix: String, state: Option[U], args: RemainingArgs): List[CompletionItem] = 84 | self.optionName(prefix, state.flatMap(f), args) 85 | def optionValue( 86 | arg: Arg, 87 | prefix: String, 88 | state: Option[U], 89 | args: RemainingArgs 90 | ): List[CompletionItem] = 91 | self.optionValue(arg, prefix, state.flatMap(f), args) 92 | def argument(prefix: String, state: Option[U], args: RemainingArgs): List[CompletionItem] = 93 | self.argument(prefix, state.flatMap(f), args) 94 | 95 | override def postDoubleDash(state: Option[U], args: RemainingArgs): Option[Completer[U]] = 96 | self.postDoubleDash(state.flatMap(f), args).map(_.contramapOpt(f)) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/help/WordUtils.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.help 2 | 3 | import java.util.regex.Pattern 4 | 5 | object WordUtils { 6 | 7 | // adapted from https://github.com/apache/commons-lang/blob/601e976b0d5a9bb323fd2625c8d3751d1547a5d2/src/main/java/org/apache/commons/lang3/text/WordUtils.java#L273-L346 8 | def wrap( 9 | str: String, 10 | wrapLength: Int, 11 | newLineStrOpt: Option[String], 12 | wrapLongWords: Boolean, 13 | wrapOn: String 14 | ): String = { 15 | 16 | val newLineStr = newLineStrOpt.getOrElse(System.lineSeparator()) 17 | 18 | val wrapLength0 = 19 | if (wrapLength < 1) 1 20 | else wrapLength 21 | 22 | val wrapOn0 = 23 | if (isBlank(wrapOn)) " " 24 | else wrapOn 25 | 26 | val patternToWrapOn = Pattern.compile(wrapOn0) 27 | val inputLineLength = str.length 28 | var offset = 0 29 | val wrappedLine = new java.lang.StringBuilder(inputLineLength + 32) 30 | 31 | var shouldStop = false 32 | 33 | while (!shouldStop && offset < inputLineLength) { 34 | var spaceToWrapAt = -1 35 | var matcher = patternToWrapOn.matcher( 36 | str.substring( 37 | offset, 38 | Math.min( 39 | Math.min(Integer.MAX_VALUE.toLong, offset + wrapLength0 + 1L).toInt, 40 | inputLineLength 41 | ) 42 | ) 43 | ) 44 | val found = matcher.find() 45 | if (found && matcher.start() == 0) 46 | offset += matcher.end() 47 | else { 48 | if (found) 49 | spaceToWrapAt = matcher.start() + offset 50 | 51 | // only last line without leading spaces is left 52 | if (inputLineLength - offset <= wrapLength0) 53 | shouldStop = true 54 | else { 55 | while (matcher.find()) 56 | spaceToWrapAt = matcher.start() + offset 57 | 58 | if (spaceToWrapAt >= offset) { 59 | // normal case 60 | wrappedLine.append(str.drop(offset).take(spaceToWrapAt)) 61 | wrappedLine.append(newLineStr) 62 | offset = spaceToWrapAt + 1 63 | } 64 | else // really long word or URL 65 | if (wrapLongWords) { 66 | // wrap really long word one line at a time 67 | wrappedLine.append(str.drop(offset).take(wrapLength0 + offset)) 68 | wrappedLine.append(newLineStr) 69 | offset += wrapLength0 70 | } 71 | else { 72 | // do not wrap really long word, just extend beyond limit 73 | matcher = patternToWrapOn.matcher(str.substring(offset + wrapLength0)) 74 | if (matcher.find()) 75 | spaceToWrapAt = matcher.start() + offset + wrapLength0 76 | 77 | if (spaceToWrapAt >= 0) { 78 | wrappedLine.append(str.drop(offset).take(spaceToWrapAt)) 79 | wrappedLine.append(newLineStr) 80 | offset = spaceToWrapAt + 1 81 | } 82 | else { 83 | wrappedLine.append(str.drop(offset).take(str.length())) 84 | offset = inputLineLength 85 | } 86 | } 87 | } 88 | } 89 | } 90 | 91 | // Whatever is left in line is short enough to just pass through 92 | wrappedLine.append(str.drop(offset).take(str.length())) 93 | 94 | wrappedLine.toString 95 | } 96 | 97 | // adapted from https://github.com/apache/commons-lang/blob/601e976b0d5a9bb323fd2625c8d3751d1547a5d2/src/main/java/org/apache/commons/lang3/StringUtils.java#L3573-L3584 98 | private def isBlank(cs: CharSequence): Boolean = 99 | cs.length == 0 || 100 | (0 until cs.length).forall(i => Character.isWhitespace(cs.charAt(i))) 101 | } 102 | -------------------------------------------------------------------------------- /tests/jvm-native/src/test/scala/caseapp/CompletionInstallTests.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | 3 | import utest._ 4 | 5 | object CompletionInstallTests extends TestSuite { 6 | 7 | private def shellTest( 8 | shell: String, 9 | expectedFiles: Seq[(os.SubPath, String)] 10 | ): Unit = { 11 | val shell0 = shell 12 | val dir = os.temp.dir(prefix = "case-app-test") 13 | try { 14 | val prog = new CommandsEntryPoint { 15 | override def completionHome = dir.toNIO 16 | override def shell = Some(shell0) 17 | override def completionXdgHome = None 18 | override def completionZDotDir = None 19 | override def enableCompletionsCommand = true 20 | def progName = "prog" 21 | def commands = Seq( 22 | CompletionDefinitions.Commands.First, 23 | CompletionDefinitions.Commands.Second, 24 | CompletionDefinitions.Commands.BackTick 25 | ) 26 | } 27 | 28 | prog.main(Array("completions", "install")) 29 | def listFiles() = os.walk(dir) 30 | .filter(!os.isDir(_)) 31 | .map { f => 32 | val relPath = f.relativeTo(dir).asSubPath 33 | val content = os.read(f).replaceAll(dir.toString, "\\$HOME") 34 | (relPath, content) 35 | } 36 | .sortBy(_._1.toString) 37 | val files = listFiles() 38 | val expectedFiles0 = expectedFiles.sortBy(_._1.toString) 39 | if (files != expectedFiles0) { 40 | pprint.err.log(expectedFiles0) 41 | pprint.err.log(files) 42 | } 43 | assert(files == expectedFiles0) 44 | 45 | prog.main(Array("completions", "uninstall")) 46 | val remaining = listFiles().filter(_._2.nonEmpty) 47 | if (!remaining.isEmpty) 48 | pprint.err.log(remaining) 49 | assert(remaining.isEmpty) 50 | } 51 | finally os.remove.all(dir) 52 | } 53 | 54 | val tests = Tests { 55 | test("zsh") { 56 | shellTest( 57 | "zsh", 58 | Seq( 59 | os.sub / ".config" / "zsh" / "completions" / "_prog" -> 60 | """#compdef _prog prog 61 | | 62 | |function _prog { 63 | | eval "$(prog complete zsh-v1 $CURRENT $words[@])" 64 | |} 65 | |""".stripMargin, 66 | os.sub / ".zshrc" -> 67 | """ 68 | |# >>> prog completions >>> 69 | |fpath=("$HOME/.config/zsh/completions" $fpath) 70 | |compinit 71 | |# <<< prog completions <<< 72 | |""".stripMargin 73 | ) 74 | ) 75 | } 76 | 77 | test("bash") { 78 | shellTest( 79 | "bash", 80 | Seq( 81 | os.sub / ".bashrc" -> 82 | """ 83 | |# >>> prog completions >>> 84 | |_prog_completions() { 85 | | local IFS=$'\n' 86 | | eval "$(prog complete bash-v1 "$(( $COMP_CWORD + 1 ))" "${COMP_WORDS[@]}")" 87 | |} 88 | | 89 | |complete -F _prog_completions prog 90 | |# <<< prog completions <<< 91 | |""".stripMargin 92 | ) 93 | ) 94 | } 95 | 96 | test("fish") { 97 | shellTest( 98 | "fish", 99 | Seq( 100 | os.sub / ".config" / "fish" / "completions" / "prog.fish" -> 101 | """ 102 | |# >>> prog completions >>> 103 | |complete prog -a '(prog complete fish-v1 (math 1 + (count (__fish_print_cmd_args))) (__fish_print_cmd_args))' 104 | |# <<< prog completions <<< 105 | |""".stripMargin 106 | ) 107 | ) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-2/caseapp/core/parser/ParserOps.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.parser 2 | 3 | import caseapp.{HelpMessage, Name, Recurse, ValueDescription} 4 | import caseapp.core.argparser.ArgParser 5 | import caseapp.core.Arg 6 | import shapeless.{::, Generic, HList, ops} 7 | import caseapp.core.util.Formatter 8 | 9 | class ParserOps[T <: HList, D <: HList](val parser: Parser.Aux[T, D]) extends AnyVal { 10 | 11 | // FIXME group is missing 12 | def add[H: ArgParser]( 13 | name: String, 14 | default: => Option[H] = None, 15 | extraNames: Seq[Name] = Nil, 16 | valueDescription: Option[ValueDescription] = None, 17 | helpMessage: Option[HelpMessage] = None, 18 | noHelp: Boolean = false, 19 | isFlag: Boolean = false, 20 | formatter: Formatter[Name] = Formatter.DefaultNameFormatter 21 | ): Parser.Aux[H :: T, Option[H] :: D] = { 22 | val argument = Argument( 23 | Arg( 24 | Name(name), 25 | extraNames, 26 | valueDescription, 27 | helpMessage, 28 | noHelp, 29 | isFlag 30 | ), 31 | ArgParser[H], 32 | () => default 33 | ) 34 | ConsParser(argument, parser) 35 | } 36 | 37 | def addAll[U]: ParserOps.AddAllHelper[T, D, U] = 38 | new ParserOps.AddAllHelper[T, D, U](parser) 39 | 40 | def as[F](implicit helper: ParserOps.AsHelper[T, F]): Parser.Aux[F, D] = 41 | helper(parser) 42 | 43 | def tupled[P](implicit helper: ParserOps.TupledHelper[T, P]): Parser.Aux[P, D] = 44 | helper(parser) 45 | 46 | def to[F](implicit helper: ParserOps.ToHelper[T, F]): Parser.Aux[F, D] = 47 | helper(parser) 48 | 49 | def toTuple[P](implicit helper: ParserOps.ToTupleHelper[T, P]): Parser.Aux[P, D] = 50 | helper(parser) 51 | 52 | } 53 | 54 | object ParserOps { 55 | 56 | class AddAllHelper[T <: HList, D <: HList, U](val parser: Parser.Aux[T, D]) extends AnyVal { 57 | def apply[DU](implicit other: Parser.Aux[U, DU]): Parser.Aux[U :: T, DU :: D] = 58 | RecursiveConsParser(other, parser, Recurse()) 59 | } 60 | 61 | sealed abstract class AsHelper[T, F] { 62 | def apply[D](parser: Parser.Aux[T, D]): Parser.Aux[F, D] 63 | } 64 | 65 | implicit def defaultAsHelper[F, T <: HList, R <: HList](implicit 66 | gen: Generic.Aux[F, R], 67 | rev: ops.hlist.Reverse.Aux[T, R] 68 | ): AsHelper[T, F] = 69 | new AsHelper[T, F] { 70 | def apply[D](parser: Parser.Aux[T, D]) = 71 | parser 72 | .map(rev.apply) 73 | .map(gen.from) 74 | } 75 | 76 | sealed abstract class ToHelper[T, F] { 77 | def apply[D](parser: Parser.Aux[T, D]): Parser.Aux[F, D] 78 | } 79 | 80 | implicit def defaultToHelper[F, T <: HList](implicit 81 | gen: Generic.Aux[F, T] 82 | ): ToHelper[T, F] = 83 | new ToHelper[T, F] { 84 | def apply[D](parser: Parser.Aux[T, D]) = 85 | parser 86 | .map(gen.from) 87 | } 88 | 89 | sealed abstract class TupledHelper[T, P] { 90 | def apply[D](parser: Parser.Aux[T, D]): Parser.Aux[P, D] 91 | } 92 | 93 | implicit def defaultTupledHelper[P, T <: HList, R <: HList](implicit 94 | rev: ops.hlist.Reverse.Aux[T, R], 95 | tupler: ops.hlist.Tupler.Aux[R, P] 96 | ): TupledHelper[T, P] = 97 | new TupledHelper[T, P] { 98 | def apply[D](parser: Parser.Aux[T, D]) = 99 | parser 100 | .map(rev.apply) 101 | .map(tupler.apply) 102 | } 103 | 104 | sealed abstract class ToTupleHelper[T, P] { 105 | def apply[D](parser: Parser.Aux[T, D]): Parser.Aux[P, D] 106 | } 107 | 108 | implicit def defaultToTupleHelper[P, T <: HList](implicit 109 | tupler: ops.hlist.Tupler.Aux[T, P] 110 | ): ToTupleHelper[T, P] = 111 | new ToTupleHelper[T, P] { 112 | def apply[D](parser: Parser.Aux[T, D]) = 113 | parser 114 | .map(tupler.apply) 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /docs/pages/parse.md: -------------------------------------------------------------------------------- 1 | # Parsing options 2 | 3 | case-app offers several ways to parse input arguments: 4 | 5 | - method calls, 6 | - app definitions. 7 | 8 | ## Method calls 9 | 10 | The `CaseApp` object contains a number of methods that can be used to parse arguments. 11 | 12 | ### `parse` 13 | 14 | ```scala mdoc:invisible:reset 15 | import caseapp._ 16 | ``` 17 | 18 | `CaseApp.parse` accepts a sequence of strings, and returns either an error or 19 | parsed options and remaining arguments: 20 | ```scala mdoc:silent 21 | case class Options( 22 | foo: Int = 0 23 | ) 24 | val args = Seq("a", "--foo", "2", "b") 25 | val (options, remaining) = CaseApp.parse[Options](args).toOption.get 26 | assert(options == Options(2)) 27 | assert(remaining == Seq("a", "b")) 28 | 29 | val either = CaseApp.parse[Options](Seq("--foo", "a")) 30 | assert(either.left.toOption.nonEmpty) 31 | ``` 32 | 33 | ### `parseWithHelp` 34 | 35 | `CaseApp.parseWithHelp` does the same as `CaseApp.parse`, but also accepts 36 | `--help` / `-h` / `--usage` options. 37 | 38 | ```scala mdoc:silent 39 | CaseApp.parseWithHelp[Options](args) match { 40 | case Left(error) => // invalid options… 41 | case Right((Left(error), helpAsked, usageAsked, remaining)) => 42 | // missing mandatory options, but --help or --usage could be parsed 43 | case Right((Right(options), helpAsked, usageAsked, remaining)) => 44 | // All is well: 45 | // Options were parsed, resulting in options 46 | // helpAsked and / or usageAsked are true if either has been requested 47 | // remaining contains non-option arguments 48 | } 49 | ``` 50 | 51 | ### `detailedParse`, `detailedParseWithHelp` 52 | 53 | `CaseApp.detailedParse` and `CaseApp.detailedParseWithHelp` behave the same way 54 | as `CaseApp.parse` and `CaseApp.parseWithHelp`, but return their remaining arguments 55 | as a `RemainingArgs` instance, rather than a `Seq[String]`. See [below](#double-hyphen) 56 | for what `RemainingArgs` brings. 57 | 58 | ### `process` 59 | 60 | `CaseApp.process` is the most straightforward method to parse arguments. Note that 61 | it exits the current application if parsing arguments fails or if users request 62 | help, with `--help` for example. 63 | 64 | ```scala mdoc:reset:invisible 65 | val args = Array("a") 66 | ``` 67 | 68 | It aims at being used from Scala CLI `.sc` files ("Scala scripts"), where one 69 | would rather have case-app handle all errors cases, like 70 | ```scala mdoc:silent 71 | //> using dep com.github.alexarchambault::case-app::@VERSION@ 72 | import caseapp._ 73 | 74 | case class Options( 75 | foo: Int = 0, 76 | path: Option[String] = None 77 | ) 78 | 79 | val (options, remaining) = CaseApp.process[Options](args.toSeq) 80 | 81 | // … 82 | ``` 83 | 84 | ## Application definition 85 | 86 | case-app allows one to alter one's main class definitions, so that one 87 | defines a method accepting parsed options, rather than raw arguments: 88 | 89 | ```scala mdoc:reset-object:invisible 90 | import caseapp._ 91 | ``` 92 | 93 | ```scala mdoc:silent 94 | case class Options( 95 | foo: Int = 0 96 | ) 97 | 98 | object MyApp extends CaseApp[Options] { 99 | def run(options: Options, remaining: RemainingArgs): Unit = { 100 | ??? 101 | } 102 | } 103 | ``` 104 | 105 | In that example, case-app defines the `MainApp#main` method, so that 106 | `MyApp` can be used as a "main class". 107 | 108 | ## Parsing 109 | 110 | ### Double-hyphen 111 | 112 | ```scala mdoc:invisible:reset 113 | import caseapp._ 114 | ``` 115 | 116 | case-app assumes any argument after `--` is not an option. It stops looking 117 | for options after `--`. Arguments before and after `--` can be differentiated 118 | in the `RemainingArgs` class: 119 | ```scala mdoc:silent 120 | case class Options() 121 | val (_, args) = CaseApp.detailedParse[Options](Seq("first", "--", "foo")).toOption.get 122 | ``` 123 | ```scala mdoc 124 | args.remaining 125 | args.unparsed 126 | args.all 127 | ``` 128 | -------------------------------------------------------------------------------- /tests/shared/src/test/scala/caseapp/CompletionDefinitions.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | 3 | import caseapp.core.Arg 4 | import caseapp.core.app.CommandsEntryPoint 5 | import caseapp.core.complete._ 6 | 7 | object CompletionDefinitions { 8 | object Simple { 9 | case class Options(value: String) 10 | object App extends CaseApp[Options] { 11 | def run(options: Options, args: RemainingArgs): Unit = ??? 12 | } 13 | } 14 | 15 | object Multiple { 16 | case class Options(@Name("V") @HelpMessage("A value") value: String, @Name("n") other: Int) 17 | object App extends CaseApp[Options] { 18 | def run(options: Options, args: RemainingArgs): Unit = ??? 19 | } 20 | } 21 | 22 | object ArgCompletion { 23 | case class Options( 24 | @Name("V") @HelpMessage("A value") value: String = "", 25 | @Name("n") other: Int = 0 26 | ) 27 | object App extends CaseApp[Options] { 28 | override def completer: Completer[Options] = { 29 | val parent = super.completer 30 | new Completer[Options] { 31 | def optionName(prefix: String, state: Option[Options], args: RemainingArgs) = 32 | parent.optionName(prefix, state, args) 33 | def optionValue(arg: Arg, prefix: String, state: Option[Options], args: RemainingArgs) = 34 | if (arg.name.name == "value") 35 | state match { 36 | case None => parent.optionValue(arg, prefix, state, args) 37 | case Some(state0) => 38 | (0 to 2) 39 | .map(_ + state0.other * 1000) 40 | .map(n => CompletionItem(n.toString)) 41 | .toList 42 | } 43 | else 44 | parent.optionValue(arg, prefix, state, args) 45 | def argument(prefix: String, state: Option[Options], args: RemainingArgs) = 46 | parent.argument(prefix, state, args) 47 | } 48 | } 49 | def run(options: Options, args: RemainingArgs): Unit = ??? 50 | } 51 | } 52 | 53 | object Commands { 54 | case class FirstOptions( 55 | @Name("V") @HelpMessage("A value") value: String = "", 56 | @Name("n") other: Int = 0 57 | ) 58 | case class SecondOptions( 59 | @Name("g") @HelpMessage("A pattern") glob: String = "", 60 | @Name("d") count: Int = 0 61 | ) 62 | case class BackTickOptions( 63 | @HelpMessage( 64 | """A pattern with backtick `--` 65 | |with multiline""".stripMargin 66 | ) backtick: String = "", 67 | @Name("d") count: Int = 0 68 | ) 69 | object First extends Command[FirstOptions] { 70 | def run(options: FirstOptions, args: RemainingArgs): Unit = ??? 71 | } 72 | object Second extends Command[SecondOptions] { 73 | def run(options: SecondOptions, args: RemainingArgs): Unit = ??? 74 | } 75 | object BackTick extends Command[BackTickOptions] { 76 | def run(options: BackTickOptions, args: RemainingArgs): Unit = ??? 77 | } 78 | 79 | object Prog extends CommandsEntryPoint { 80 | def progName = "prog" 81 | def commands = Seq( 82 | First, 83 | Second, 84 | BackTick 85 | ) 86 | } 87 | } 88 | 89 | object CommandsWithDefault { 90 | case class FirstOptions( 91 | @Name("V") @HelpMessage("A value") value: String = "", 92 | @Name("n") other: Int = 0 93 | ) 94 | case class SecondOptions( 95 | @Name("g") @HelpMessage("A pattern") glob: String = "", 96 | @Name("d") count: Int = 0 97 | ) 98 | object First extends Command[FirstOptions] { 99 | def run(options: FirstOptions, args: RemainingArgs): Unit = ??? 100 | } 101 | object Second extends Command[SecondOptions] { 102 | def run(options: SecondOptions, args: RemainingArgs): Unit = ??? 103 | } 104 | 105 | object Prog extends CommandsEntryPoint { 106 | def progName = "prog" 107 | override def defaultCommand = Some(First) 108 | def commands = Seq( 109 | First, 110 | Second 111 | ) 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/caseapp/core/help/RuntimeCommandsHelp.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core.help 2 | 3 | import caseapp.core.Arg 4 | import caseapp.core.app.Command 5 | import caseapp.core.util.fansi 6 | import caseapp.core.util.NameOps.toNameOps 7 | import dataclass._ 8 | 9 | @data case class RuntimeCommandsHelp( 10 | progName: String, 11 | description: Option[String], 12 | defaultHelp: Help[_], 13 | commands: Seq[RuntimeCommandHelp[_]], 14 | summaryDesc: Option[String] 15 | ) { 16 | 17 | def help(): String = 18 | help(HelpFormat.default(), showHidden = false) 19 | def help(format: HelpFormat): String = 20 | help(format, showHidden = false) 21 | 22 | def help(format: HelpFormat, showHidden: Boolean): String = { 23 | val b = new StringBuilder 24 | b.append("Usage: ") 25 | b.append(format.progName(progName).render) 26 | 27 | if (commands.nonEmpty) 28 | b.append(" ") 29 | 30 | if (defaultHelp.args.nonEmpty) { 31 | b.append(" ") 32 | b.append(defaultHelp.optionsDesc) 33 | } 34 | 35 | for (argName <- defaultHelp.argsNameOption) { 36 | b.append(" [") 37 | b.append(argName) 38 | b.append("]") 39 | } 40 | 41 | b.append(format.newLine) 42 | 43 | for (desc <- description) 44 | Help.printDescription( 45 | b, 46 | desc, 47 | format.newLine, 48 | format.terminalWidthOpt.getOrElse(Int.MaxValue) 49 | ) 50 | 51 | if (defaultHelp.nonEmpty) { 52 | b.append(format.newLine) 53 | defaultHelp.printOptions(b, format, showHidden) 54 | b.append(format.newLine) 55 | } 56 | 57 | if (commands.nonEmpty) { 58 | b.append(format.newLine) 59 | printCommands(b, format, showHidden) 60 | } 61 | 62 | for (argName <- summaryDesc) { 63 | b.append(format.newLine) 64 | b.append(format.newLine) 65 | b.append(argName) 66 | } 67 | 68 | b.result() 69 | } 70 | 71 | def printCommands(b: StringBuilder, format: HelpFormat, showHidden: Boolean): Unit = 72 | if (commands.nonEmpty) { 73 | 74 | val grouped = format.sortCommandGroupValues( 75 | commands 76 | .filter(c => showHidden || !c.hidden) 77 | .groupBy(_.group) 78 | .toVector, 79 | showHidden 80 | ) 81 | 82 | def table(commands: Seq[RuntimeCommandHelp[_]]) = 83 | Table { 84 | commands 85 | .iterator 86 | .map { help => 87 | val names0 = 88 | help.names.map(_.mkString(" ")).map(format.commandName(_).render).mkString(", ") 89 | val baseDescOpt = help.help.helpMessage 90 | .flatMap { m => 91 | m.message 92 | .linesIterator 93 | .map(_.trim) 94 | .filter(_.nonEmpty) 95 | .toStream 96 | .headOption 97 | } 98 | val descOpt = 99 | if (help.hidden) 100 | Some { 101 | format.hidden("(hidden)") ++ 102 | (baseDescOpt.map(" " + _).getOrElse(""): String) 103 | } 104 | else baseDescOpt.map(x => x: fansi.Str) 105 | Seq(names0: fansi.Str, descOpt.getOrElse("": fansi.Str)) 106 | } 107 | .toVector 108 | } 109 | 110 | grouped 111 | .iterator 112 | .zipWithIndex 113 | .foreach { 114 | case ((groupName, groupCommands), idx) => 115 | if (idx > 0) { 116 | b.append(format.newLine) 117 | b.append(format.newLine) 118 | } 119 | val printedName = 120 | if (groupName.isEmpty) 121 | if (grouped.length == 1) "Commands:" 122 | else "Other commands:" 123 | else s"$groupName commands:" 124 | b.append(printedName) 125 | b.append(format.newLine) 126 | val table0 = table(groupCommands) 127 | table0.render(b, " ", " ", format.newLine, table0.widths.map(_.min(45)).toVector) 128 | } 129 | } 130 | 131 | } 132 | -------------------------------------------------------------------------------- /tests/shared/src/test/scala/caseapp/demo/Demo.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | package demo 3 | 4 | import caseapp.core.util.Formatter 5 | 6 | final case class SharedOptions( 7 | other: String = "" 8 | ) 9 | 10 | object SharedOptions { 11 | implicit val parser: Parser[SharedOptions] = Parser.derive 12 | implicit val help: Help[SharedOptions] = Help.derive 13 | } 14 | 15 | @AppVersion("0.1.0") 16 | @ArgsName("files") 17 | final case class DemoOptions( 18 | first: Boolean, 19 | @Group("foo") 20 | @Tag("foo") 21 | @ExtraName("V") 22 | @HelpMessage("Set a value") 23 | value: Option[String], 24 | @ExtraName("v") @HelpMessage("Be verbose") verbose: Int @@ Counter, 25 | @Tag("foo") 26 | @Tag("other") 27 | @ExtraName("S") 28 | @ValueDescription("stages") 29 | stages: List[String], 30 | @Recurse shared: SharedOptions = SharedOptions() 31 | ) 32 | 33 | object DemoOptions { 34 | implicit val parser: Parser[DemoOptions] = Parser.derive 35 | implicit val help: Help[DemoOptions] = Help.derive 36 | } 37 | 38 | object Demo extends CaseApp[DemoOptions] { 39 | 40 | def run(options: DemoOptions, remainingArgs: RemainingArgs) = 41 | Console.err.println(this) 42 | } 43 | 44 | @AppName("Glorious App") 45 | @AppVersion("0.1.0") 46 | final case class MyAppOptions( 47 | @ValueDescription("a foo") @HelpMessage("Specify some foo") foo: Option[String], 48 | bar: Int 49 | ) 50 | 51 | object MyApp extends CaseApp[MyAppOptions] { 52 | def run(options: MyAppOptions, remainingArgs: RemainingArgs) = {} 53 | } 54 | 55 | @AppName("Demo") 56 | @AppVersion("1.0.0") 57 | @ProgName("demo-cli") 58 | sealed abstract class DemoCommand extends Product with Serializable 59 | 60 | final case class First( 61 | @ExtraName("v") verbose: Int @@ Counter, 62 | @ValueDescription("a bar") @HelpMessage("Set bar") bar: String = "default-bar" 63 | ) extends DemoCommand 64 | 65 | @CommandName("second") 66 | @HelpMessage("second cmd help") 67 | final case class Secondd( 68 | extra: List[Int], 69 | @ExtraName("S") 70 | @ValueDescription("stages") 71 | stages: List[String] 72 | ) extends DemoCommand 73 | 74 | object ManualCommandStuff { 75 | 76 | case object Command1 extends CaseApp[Command1Opts] { 77 | def run(options: Command1Opts, args: RemainingArgs): Unit = {} 78 | } 79 | 80 | case object Command2 extends CaseApp[Command2Opts] { 81 | def run(options: Command2Opts, args: RemainingArgs): Unit = {} 82 | } 83 | } 84 | 85 | object ManualCommandNotAdtStuff { 86 | 87 | case object Command1 extends CaseApp[ManualCommandNotAdtOptions.Command1Opts] { 88 | def run(options: ManualCommandNotAdtOptions.Command1Opts, args: RemainingArgs): Unit = {} 89 | } 90 | 91 | case object Command2 extends CaseApp[ManualCommandNotAdtOptions.Command2Opts] { 92 | def run(options: ManualCommandNotAdtOptions.Command2Opts, args: RemainingArgs): Unit = {} 93 | } 94 | 95 | case object Command3StopAtUnreco extends CaseApp[ManualCommandNotAdtOptions.Command3Opts] { 96 | override def stopAtFirstUnrecognized = true 97 | def run(options: ManualCommandNotAdtOptions.Command3Opts, args: RemainingArgs): Unit = {} 98 | } 99 | 100 | case object Command4NameFormatter extends CaseApp[ManualCommandNotAdtOptions.Command4Opts] { 101 | override def nameFormatter: Formatter[Name] = (name: Name) => name.name 102 | def run(options: ManualCommandNotAdtOptions.Command4Opts, args: RemainingArgs): Unit = {} 103 | } 104 | 105 | case object Command5IgnoreUnrecognized extends CaseApp[ManualCommandNotAdtOptions.Command5Opts] { 106 | override def ignoreUnrecognized = true 107 | def run(options: ManualCommandNotAdtOptions.Command5Opts, args: RemainingArgs): Unit = {} 108 | } 109 | } 110 | 111 | object ManualSubCommandStuff { 112 | 113 | case object Command1 extends CaseApp[ManualSubCommandOptions.Command1Opts] { 114 | def run(options: ManualSubCommandOptions.Command1Opts, args: RemainingArgs): Unit = {} 115 | } 116 | 117 | case object Command2 extends CaseApp[ManualSubCommandOptions.Command2Opts] { 118 | def run(options: ManualSubCommandOptions.Command2Opts, args: RemainingArgs): Unit = {} 119 | } 120 | } 121 | 122 | case class GenericArgs[Shared]( 123 | main: String, 124 | @Recurse shared: Shared, 125 | opt: String = "" 126 | ) 127 | -------------------------------------------------------------------------------- /tests/js/src/test/scala/caseapp/os.scala: -------------------------------------------------------------------------------- 1 | package caseapp 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.Dynamic.{global => g} 5 | 6 | import java.util.regex.Pattern 7 | 8 | object os { 9 | 10 | trait PathChunk { 11 | def segments: Seq[String] 12 | def ups: Int 13 | } 14 | 15 | object PathChunk { 16 | def checkSegment(s: String): Unit = 17 | () // TODO 18 | implicit class StringPathChunk(s: String) extends PathChunk { 19 | checkSegment(s) 20 | def segments = Seq(s) 21 | def ups = 0 22 | override def toString() = s 23 | } 24 | } 25 | 26 | class RelPath private[os] (val ups: Int, val segments: Seq[String]) { 27 | def asSubPath: SubPath = { 28 | require(ups == 0) 29 | new SubPath(segments) 30 | } 31 | 32 | override def toString(): String = 33 | (Iterator.fill(ups)("..") ++ segments.iterator).mkString("/") 34 | override def hashCode = segments.hashCode() + ups.hashCode() 35 | override def equals(o: Any): Boolean = o match { 36 | case p: RelPath => segments == p.segments && p.ups == ups 37 | case p: SubPath => segments == p.segments && ups == 0 38 | case _ => false 39 | } 40 | } 41 | 42 | class SubPath private[os] (val segments: Seq[String]) { 43 | def /(chunk: PathChunk): SubPath = { 44 | require(chunk.ups <= segments.length) 45 | new SubPath(segments.take(segments.length - chunk.ups) ++ chunk.segments) 46 | } 47 | 48 | override def toString(): String = 49 | segments.mkString("/") 50 | override def hashCode = segments.hashCode() 51 | override def equals(o: Any): Boolean = o match { 52 | case p: SubPath => segments == p.segments 53 | case p: RelPath => segments == p.segments && p.ups == 0 54 | case _ => false 55 | } 56 | } 57 | 58 | object SubPath { 59 | val sub: SubPath = new SubPath(Seq.empty) 60 | } 61 | 62 | class Path private[os] (val underlying: String) { 63 | def /(chunk: PathChunk): Path = { 64 | val elems = List.fill(chunk.ups)("..") ++ chunk.segments 65 | val newPath = nodePath 66 | .applyDynamic("join")((underlying +: elems).map(x => x: js.Any): _*) 67 | .asInstanceOf[String] 68 | new Path(newPath) 69 | } 70 | 71 | def relativeTo(other: Path): os.RelPath = { 72 | val rel = nodePath.relative(other.underlying, underlying).asInstanceOf[String] 73 | val elems = rel.split(Pattern.quote(nodePath.sep.asInstanceOf[String])).toSeq 74 | val ups = elems.takeWhile(_ == "..").length 75 | new RelPath(ups, elems.drop(ups)) 76 | } 77 | 78 | def toNIO = caseapp.core.app.nio.Path(underlying) 79 | override def toString(): String = underlying 80 | } 81 | 82 | object Path { 83 | def apply(path: String): Path = 84 | new Path(path) 85 | } 86 | 87 | def sub: SubPath = SubPath.sub 88 | 89 | private lazy val fs = g.require("fs") 90 | private lazy val nodePath = g.require("path") 91 | private lazy val nodeOs = g.require("os") 92 | 93 | object exists { 94 | def apply(path: Path): Boolean = 95 | fs.existsSync(path.underlying).asInstanceOf[Boolean] 96 | } 97 | 98 | object isDir { 99 | def apply(path: Path): Boolean = 100 | exists(path) && 101 | fs.statSync(path.underlying).isDirectory().asInstanceOf[Boolean] 102 | } 103 | 104 | object read { 105 | def apply(path: Path): String = 106 | fs.readFileSync(path.underlying, js.Dictionary("encoding" -> "utf8")) 107 | .asInstanceOf[String] 108 | } 109 | 110 | object remove { 111 | object all { 112 | def apply(path: Path): Unit = 113 | fs.rmSync(path.underlying, js.Dictionary("recursive" -> true, "force" -> true)) 114 | } 115 | } 116 | 117 | object temp { 118 | 119 | object dir { 120 | def apply(prefix: String): Path = 121 | new Path(fs.mkdtempSync(nodePath.join(nodeOs.tmpdir(), prefix)).asInstanceOf[String]) 122 | } 123 | 124 | } 125 | 126 | object walk { 127 | def apply(path: Path): Seq[Path] = 128 | fs.readdirSync(path.underlying, js.Dictionary("recursive" -> true)) 129 | .asInstanceOf[js.Array[String]] 130 | .toVector 131 | .map(subPath => Path(nodePath.join(path.underlying, subPath).asInstanceOf[String])) 132 | } 133 | 134 | } 135 | -------------------------------------------------------------------------------- /docs/pages/completion.md: -------------------------------------------------------------------------------- 1 | # Completion 2 | 3 | ## Enable support for completion 4 | 5 | Support for completion relies on [commands](commands.md). 6 | 7 | ### In commands 8 | 9 | In an application made of [commands](commands.md), enable completions by overriding 10 | the `def enableCompletionsCommand: Boolean` and `def enableCompleteCommand: Boolean`. 11 | 12 | This adds two (hidden) commands to your application: 13 | - `completions` (also aliased to `completion`): command that allows to help installing 14 | completions 15 | - `complete`: command run when users ask for completions in their shell 16 | 17 | Overriding `def completionsWorkingDirectory: Option[String]` and returning a non-empty 18 | value from it enables two more commands: 19 | - `completions install` (also aliased to `completion install`): command to install completions 20 | for the current shell 21 | - `completions uninstall` (also aliased to `completion uninstall`): command to uninstall 22 | completions for the current shell 23 | 24 | ### For simple applications 25 | 26 | ```scala mdoc:reset-object:invisible 27 | import caseapp._ 28 | ``` 29 | 30 | If you'd like to enable it in a simple application, make it extend 31 | `Command` rather than `CaseApp`, and define a `CommandsEntryPoint` 32 | with no commands, and your application as default command: 33 | ```scala mdoc:silent 34 | case class Options( 35 | foo: String = "" 36 | ) 37 | 38 | object MyActualApp extends Command[Options] { 39 | def run(options: Options, args: RemainingArgs): Unit = { 40 | ??? 41 | } 42 | } 43 | 44 | object MyApp extends CommandsEntryPoint { 45 | def progName = "my-app" 46 | def commands = Seq() 47 | override def defaultCommand = Some(MyActualApp) 48 | override def enableCompleteCommand = true 49 | override def enableCompletionsCommand = true 50 | } 51 | ``` 52 | 53 | ## Install completions 54 | 55 | ### Via `completions install` 56 | 57 | Assuming `progname` runs the main class added by `CommandEntryPoint` to the object extended 58 | by it, you can install completions with 59 | ```text 60 | $ progname completions install 61 | ``` 62 | 63 | ```scala mdoc:passthrough 64 | println("```text") 65 | for (line <- MyApp.completionsInstalledMessage("~/.zshrc", updated = false)) 66 | println(line) 67 | println("```") 68 | ``` 69 | 70 | ## Get completions 71 | 72 | The file installed by `completions install` above runs your application 73 | to get completions. It runs it via the `complete` command, like 74 | ```text 75 | $ my-app complete zsh-v1 2 my-app - 76 | ``` 77 | 78 | ```scala mdoc:passthrough 79 | println("```text") 80 | MyApp.main(Array("complete", "zsh-v1", "2", "my-app", "-")) 81 | println("```") 82 | ``` 83 | 84 | Usage: 85 | ```scala mdoc:passthrough 86 | println("```text") 87 | MyApp.main(Array("complete", "--help")) 88 | println("```") 89 | ``` 90 | 91 | ## Provide completions for individual option values 92 | 93 | ```scala mdoc:reset-object:invisible 94 | import caseapp._ 95 | ``` 96 | 97 | ```scala mdoc:silent 98 | import caseapp.core.complete.CompletionItem 99 | 100 | case class Options( 101 | foo: String = "" 102 | ) 103 | 104 | object MyActualApp extends Command[Options] { 105 | def run(options: Options, args: RemainingArgs): Unit = { 106 | ??? 107 | } 108 | 109 | override def completer = 110 | super.completer.completeOptionValue { 111 | val hardCodedValues = List( 112 | "aaa", 113 | "aab", 114 | "aac", 115 | "abb" 116 | ) 117 | (arg, prefix, state, args) => 118 | // provide hard-coded values as completions for --foo values 119 | if (arg.names.map(_.name).contains("foo")) { 120 | val items = hardCodedValues.filter(_.startsWith(prefix)).map { value => 121 | CompletionItem(value) 122 | } 123 | Some(items) 124 | } 125 | else 126 | None 127 | } 128 | } 129 | 130 | object MyApp extends CommandsEntryPoint { 131 | def progName = "my-app" 132 | def commands = Seq() 133 | override def defaultCommand = Some(MyActualApp) 134 | override def enableCompleteCommand = true 135 | override def enableCompletionsCommand = true 136 | } 137 | ``` 138 | 139 | One can then get specific completions, like 140 | ```text 141 | $ my-app complete zsh-v1 3 my-app --foo a 142 | ``` 143 | 144 | ```scala mdoc:passthrough 145 | println("```text") 146 | MyApp.main(Array("complete", "zsh-v1", "3", "my-app", "--foo", "a")) 147 | println("```") 148 | ``` 149 | 150 | ```text 151 | $ my-app complete zsh-v1 3 my-app --foo aa 152 | ``` 153 | 154 | ```scala mdoc:passthrough 155 | println("```text") 156 | MyApp.main(Array("complete", "zsh-v1", "3", "my-app", "--foo", "aa")) 157 | println("```") 158 | ``` 159 | -------------------------------------------------------------------------------- /core/shared/src/main/scala-3/caseapp/core/Scala3Helpers.scala: -------------------------------------------------------------------------------- 1 | package caseapp.core 2 | 3 | import caseapp.{Group, HelpMessage, Name} 4 | import caseapp.core.help.Help 5 | import caseapp.core.help.HelpFormat 6 | import caseapp.core.parser.StandardArgument 7 | import caseapp.core.util.fansi 8 | import caseapp.core.parser.Argument 9 | import caseapp.core.parser.ConsParser 10 | import caseapp.core.parser.EitherParser 11 | import caseapp.core.parser.Parser 12 | import caseapp.core.parser.IgnoreUnrecognizedParser 13 | import caseapp.core.parser.MappedParser 14 | import caseapp.core.parser.OptionParser 15 | import caseapp.core.parser.ParserWithNameFormatter 16 | import caseapp.core.parser.StopAtFirstUnrecognizedParser 17 | import caseapp.core.parser.RecursiveConsParser 18 | 19 | object Scala3Helpers { 20 | 21 | implicit class ArgsWithOps(private val arg: Arg) extends AnyVal { 22 | def withOrigin(origin: Option[String]): Arg = 23 | arg.copy(origin = origin) 24 | def withIsFlag(isFlag: Boolean): Arg = 25 | arg.copy(isFlag = isFlag) 26 | def withGroup(group: Option[Group]): Arg = 27 | arg.copy(group = group) 28 | def withHelpMessage(helpMessage: Option[HelpMessage]): Arg = 29 | arg.copy(helpMessage = helpMessage) 30 | def withExtraNames(extraNames: Seq[Name]): Arg = 31 | arg.copy(extraNames = extraNames) 32 | } 33 | 34 | implicit class SeveralErrorsWithOps(private val error: Error.SeveralErrors) extends AnyVal { 35 | def withTail(tail: Seq[Error.SimpleError]): Error.SeveralErrors = 36 | error.copy(tail = tail) 37 | } 38 | 39 | implicit class HelpWithOps[T](private val help: Help[T]) extends AnyVal { 40 | def withArgs(args: Seq[Arg]): Help[T] = 41 | help.copy(args = args) 42 | def withProgName(progName: String): Help[T] = 43 | help.copy(progName = progName) 44 | } 45 | 46 | implicit class HelpFormatWithOps(private val helpFormat: HelpFormat) extends AnyVal { 47 | def withProgName(progName: fansi.Attrs): HelpFormat = 48 | helpFormat.copy(progName = progName) 49 | def withCommandName(commandName: fansi.Attrs): HelpFormat = 50 | helpFormat.copy(commandName = commandName) 51 | def withOption(option: fansi.Attrs): HelpFormat = 52 | helpFormat.copy(option = option) 53 | def withHidden(hidden: fansi.Attrs): HelpFormat = 54 | helpFormat.copy(hidden = hidden) 55 | def withSortGroups(sortGroups: Option[Seq[String] => Seq[String]]): HelpFormat = 56 | helpFormat.copy(sortGroups = sortGroups) 57 | def withSortedGroups(sortedGroups: Option[Seq[String]]): HelpFormat = 58 | helpFormat.copy(sortedGroups = sortedGroups) 59 | def withHiddenGroups(hiddenGroups: Option[Seq[String]]): HelpFormat = 60 | helpFormat.copy(hiddenGroups = hiddenGroups) 61 | 62 | def withFilterArgs(filterArgs: Option[Arg => Boolean]): HelpFormat = 63 | helpFormat.copy(filterArgs = filterArgs) 64 | 65 | def withFilterArgsWhenShowHidden(filterArgs: Option[Arg => Boolean]): HelpFormat = 66 | helpFormat.copy(filterArgsWhenShowHidden = filterArgs) 67 | 68 | def withHiddenGroupsWhenShowHidden(hiddenGroups: Option[Seq[String]]): HelpFormat = 69 | helpFormat.copy(hiddenGroupsWhenShowHidden = hiddenGroups) 70 | 71 | def withNamesLimit(newNamesLimit: Option[Int]): HelpFormat = 72 | helpFormat.copy(namesLimit = newNamesLimit) 73 | 74 | def withSortedCommandGroups(newSortedCommandGroups: Option[Seq[String]]): HelpFormat = 75 | helpFormat.copy(sortedCommandGroups = newSortedCommandGroups) 76 | } 77 | 78 | implicit class OptionParserWithOps[T](private val parser: OptionParser[T]) { 79 | def withUnderlying(underlying: Parser[T]): OptionParser[T] = 80 | parser.copy(underlying = underlying) 81 | } 82 | 83 | implicit class ConsParserWithOps[H, T <: Tuple](private val parser: ConsParser[H, T]) 84 | extends AnyVal { 85 | def withArgument(argument: Argument[H]): ConsParser[H, T] = 86 | parser.copy(argument = argument) 87 | def withTail(tail: Parser[T]): ConsParser[H, T] = 88 | parser.copy(tail = tail) 89 | } 90 | 91 | implicit class RecursiveConsParserWithOps[H, T <: Tuple]( 92 | private val parser: RecursiveConsParser[H, T] 93 | ) extends AnyVal { 94 | def withHeadParser(headParser: Parser[H]): RecursiveConsParser[H, T] = 95 | parser.copy(headParser = headParser) 96 | def withTailParser(tailParser: Parser[T]): RecursiveConsParser[H, T] = 97 | parser.copy(tailParser = tailParser) 98 | } 99 | 100 | implicit class StandardArgumentWithOps[H](private val arg: StandardArgument[H]) extends AnyVal { 101 | def withDefault(default: () => Option[H]): StandardArgument[H] = 102 | arg.copy(default = default) 103 | } 104 | 105 | } 106 | --------------------------------------------------------------------------------