From 8982303072a72e7a8156710caed5c54f481005c9 Mon Sep 17 00:00:00 2001 From: Li Haoyi Date: Fri, 26 Jan 2024 09:14:12 +0800 Subject: [PATCH] Add configurable automatic name mapping from `camelCase` to `kebab-case` (#101) Fixes https://github.com/com-lihaoyi/mainargs/issues/16 * We maintain backwards compatibility by continuing to allow the `camelCase` names in addition to the `kebab-case` names during argument parsing. * When a an explicit `name = ???` is given to the `@main` or `@arg` annotation, that takes precedence over everything, and is not affected by the name mapping, * Name mapping is configurable by passing in `nameMapper = mainargs.Util.snakeCaseNameMapper` or `nameMapper = mainargs.Util.nullNameMapper` when you call `ParserForClass` or `ParserForMethods` * I had to add a whole bunch of annoying shims to maintain binary compatibility when threading the new `nameMapper` through all our method signatures. That would be resolved by a proposal like https://contributors.scala-lang.org/t/can-we-make-adding-a-parameter-with-a-default-value-binary-compatible/6132/3, which alas does not exist yet in the Scala implementation * The duplication in method argument lists is getting very annoying. Again, this would be solved by a proposal like https://contributors.scala-lang.org/t/unpacking-classes-into-method-argument-lists/6329, which still doesn't exist in the language * Bumping to 0.6.0 since we cannot maintain bincompat for Scala 3 and Scala 2 simultaneously * There is no way to continue to evolve the `case class`es that is compatible with both Scala 2 and Scala 3, due to differing method signature requirements. e.g. `def unapply(x: MyCaseClass): Option[Tuple]` vs `def unapply(x: MyCaseClass): MyCaseClass`. * The choice is either to break bincompat in Scala 2 or break bincompat in Scala 2, and I ended up choosing to do so in Scala 2 since those would have the larger slower-moving codebases with more of a concern for binary compatibility * Updated the docs and added coverage in the unit tests * I intend to release this as 0.5.5 once it lands --- .github/workflows/actions.yml | 4 +- .mill-version | 2 +- mainargs/src/Invoker.scala | 36 ++- mainargs/src/Parser.scala | 243 ++++++++++++++++++--- mainargs/src/Renderer.scala | 138 +++++++++--- mainargs/src/TokenGrouping.scala | 20 +- mainargs/src/TokensReader.scala | 167 ++++++++++++-- mainargs/src/Util.scala | 38 ++++ mainargs/test/src/Checker.scala | 4 +- mainargs/test/src/CoreTests.scala | 2 +- mainargs/test/src/DashedArgumentName.scala | 108 +++++++-- mainargs/test/src/EqualsSyntaxTests.scala | 2 +- mill | 2 +- readme.md | 31 ++- 14 files changed, 668 insertions(+), 129 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 0d17d7f..59f11d9 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -26,7 +26,7 @@ jobs: distribution: 'temurin' java-version: ${{ matrix.java }} - name: Run tests - run: ./mill -i __.publishArtifacts + __.test + run: ./mill -i -k __.publishArtifacts + __.test check-binary-compatibility: runs-on: ubuntu-latest steps: @@ -38,7 +38,7 @@ jobs: distribution: 'temurin' java-version: 8 - name: Check Binary Compatibility - run: ./mill -i __.mimaReportBinaryIssues + run: ./mill -i -k __.mimaReportBinaryIssues publish-sonatype: if: github.repository == 'com-lihaoyi/mainargs' && startsWith(github.ref, 'refs/tags/') diff --git a/.mill-version b/.mill-version index badd018..ecd2d5d 100644 --- a/.mill-version +++ b/.mill-version @@ -1,2 +1,2 @@ -0.11.3 +0.11.6 diff --git a/mainargs/src/Invoker.scala b/mainargs/src/Invoker.scala index a937d66..732dc31 100644 --- a/mainargs/src/Invoker.scala +++ b/mainargs/src/Invoker.scala @@ -5,7 +5,15 @@ object Invoker { cep: TokensReader.Class[T], args: Seq[String], allowPositional: Boolean, - allowRepeats: Boolean + allowRepeats: Boolean, + ): Result[T] = construct(cep, args, allowPositional, allowRepeats, Util.nullNameMapper) + + def construct[T]( + cep: TokensReader.Class[T], + args: Seq[String], + allowPositional: Boolean, + allowRepeats: Boolean, + nameMapper: String => Option[String] ): Result[T] = { TokenGrouping .groupArgs( @@ -13,7 +21,8 @@ object Invoker { cep.main.flattenedArgSigs, allowPositional, allowRepeats, - cep.main.argSigs0.exists(_.reader.isLeftover) + cep.main.argSigs0.exists(_.reader.isLeftover), + nameMapper ) .flatMap((group: TokenGrouping[Any]) => invoke(cep.companion(), cep.main, group)) } @@ -82,7 +91,15 @@ object Invoker { mains: MethodMains[B], args: Seq[String], allowPositional: Boolean, - allowRepeats: Boolean + allowRepeats: Boolean): Either[Result.Failure.Early, (MainData[Any, B], Result[Any])] = { + runMains(mains, args, allowPositional, allowRepeats, Util.nullNameMapper) + } + def runMains[B]( + mains: MethodMains[B], + args: Seq[String], + allowPositional: Boolean, + allowRepeats: Boolean, + nameMapper: String => Option[String] ): Either[Result.Failure.Early, (MainData[Any, B], Result[Any])] = { def groupArgs(main: MainData[Any, B], argsList: Seq[String]) = { def invokeLocal(group: TokenGrouping[Any]) = @@ -98,7 +115,8 @@ object Invoker { main.argSigs0.exists { case x: ArgSig => x.reader.isLeftover case _ => false - } + }, + nameMapper ) .flatMap(invokeLocal) ) @@ -108,14 +126,16 @@ object Invoker { case Seq(main) => groupArgs(main, args) case multiple => args.toList match { - case List() => Left(Result.Failure.Early.SubcommandNotSpecified(multiple.map(_.name))) + case List() => Left(Result.Failure.Early.SubcommandNotSpecified(multiple.map(_.name(nameMapper)))) case head :: tail => if (head.startsWith("-")) { Left(Result.Failure.Early.SubcommandSelectionDashes(head)) } else { - multiple.find(_.name == head) match { - case None => - Left(Result.Failure.Early.UnableToFindSubcommand(multiple.map(_.name), head)) + multiple.find{ m => + val name = m.name(nameMapper) + name == head || (m.mainName.isEmpty && m.defaultName == head) + } match { + case None => Left(Result.Failure.Early.UnableToFindSubcommand(multiple.map(_.name(nameMapper)), head)) case Some(main) => groupArgs(main, tail) } } diff --git a/mainargs/src/Parser.scala b/mainargs/src/Parser.scala index d225b7a..790d781 100644 --- a/mainargs/src/Parser.scala +++ b/mainargs/src/Parser.scala @@ -7,12 +7,23 @@ import java.io.PrintStream object ParserForMethods extends ParserForMethodsCompanionVersionSpecific class ParserForMethods[B](val mains: MethodMains[B]) { + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def helpText( + totalWidth: Int, + docsOnNewLine: Boolean, + customNames: Map[String, String], + customDocs: Map[String, String], + sorted: Boolean): String = { + helpText(totalWidth, docsOnNewLine, customNames, customDocs, sorted, Util.kebabCaseNameMapper) + } + def helpText( totalWidth: Int = 100, docsOnNewLine: Boolean = false, customNames: Map[String, String] = Map(), customDocs: Map[String, String] = Map(), - sorted: Boolean = true + sorted: Boolean = true, + nameMapper: String => Option[String] = Util.kebabCaseNameMapper ): String = { Renderer.formatMainMethods( mains.value, @@ -20,7 +31,8 @@ class ParserForMethods[B](val mains: MethodMains[B]) { docsOnNewLine, customNames, customDocs, - sorted + sorted, + nameMapper ) } @@ -62,6 +74,28 @@ class ParserForMethods[B](val mains: MethodMains[B]) { } } + def runOrThrow( + args: Seq[String], + allowPositional: Boolean, + allowRepeats: Boolean, + totalWidth: Int, + printHelpOnExit: Boolean, + docsOnNewLine: Boolean, + autoPrintHelpAndExit: Option[(Int, PrintStream)], + customNames: Map[String, String], + customDocs: Map[String, String], + ): Any = runOrThrow( + args, + allowPositional, + allowRepeats, + totalWidth, + printHelpOnExit, + docsOnNewLine, + autoPrintHelpAndExit, + customNames, + customDocs, + ) + def runOrThrow( args: Seq[String], allowPositional: Boolean = false, @@ -71,7 +105,8 @@ class ParserForMethods[B](val mains: MethodMains[B]) { docsOnNewLine: Boolean = false, autoPrintHelpAndExit: Option[(Int, PrintStream)] = Some((0, System.out)), customNames: Map[String, String] = Map(), - customDocs: Map[String, String] = Map() + customDocs: Map[String, String] = Map(), + nameMapper: String => Option[String] = Util.kebabCaseNameMapper ): Any = { runEither( args, @@ -82,7 +117,8 @@ class ParserForMethods[B](val mains: MethodMains[B]) { docsOnNewLine, autoPrintHelpAndExit, customNames, - customDocs + customDocs, + nameMapper = nameMapper ) match { case Left(msg) => throw new Exception(msg) case Right(v) => v @@ -99,13 +135,14 @@ class ParserForMethods[B](val mains: MethodMains[B]) { autoPrintHelpAndExit: Option[(Int, PrintStream)] = Some((0, System.out)), customNames: Map[String, String] = Map(), customDocs: Map[String, String] = Map(), - sorted: Boolean = false + sorted: Boolean = false, + nameMapper: String => Option[String] = Util.kebabCaseNameMapper ): Either[String, Any] = { if (autoPrintHelpAndExit.nonEmpty && args.take(1) == Seq("--help")) { val (exitCode, outputStream) = autoPrintHelpAndExit.get - outputStream.println(helpText(totalWidth, docsOnNewLine, customNames, customDocs, sorted)) + outputStream.println(helpText(totalWidth, docsOnNewLine, customNames, customDocs, sorted, nameMapper)) Compat.exit(exitCode) - } else runRaw0(args, allowPositional, allowRepeats) match { + } else runRaw0(args, allowPositional, allowRepeats, nameMapper) match { case Left(err) => Left(Renderer.renderEarlyError(err)) case Right((main, res)) => res match { @@ -118,9 +155,10 @@ class ParserForMethods[B](val mains: MethodMains[B]) { totalWidth, printHelpOnExit, docsOnNewLine, - customNames.get(main.name), - customDocs.get(main.name), - sorted + customNames.get(main.name(nameMapper)), + customDocs.get(main.name(nameMapper)), + sorted, + nameMapper ) ) } @@ -151,23 +189,71 @@ class ParserForMethods[B](val mains: MethodMains[B]) { sorted = false ) + + + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def runEither( + args: Seq[String], + allowPositional: Boolean, + allowRepeats: Boolean, + totalWidth: Int, + printHelpOnExit: Boolean, + docsOnNewLine: Boolean, + autoPrintHelpAndExit: Option[(Int, PrintStream)], + customNames: Map[String, String], + customDocs: Map[String, String], + sorted: Boolean + ): Either[String, Any] = runEither( + args, + allowPositional, + allowRepeats, + totalWidth, + printHelpOnExit, + docsOnNewLine, + autoPrintHelpAndExit, + customNames, + customDocs, + sorted + ) + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def runRaw( + args: Seq[String], + allowPositional: Boolean, + allowRepeats: Boolean, + ): Result[Any] = runRaw( + args, allowPositional, allowRepeats, Util.kebabCaseNameMapper + ) def runRaw( args: Seq[String], allowPositional: Boolean = false, - allowRepeats: Boolean = false + allowRepeats: Boolean = false, + nameMapper: String => Option[String] = Util.kebabCaseNameMapper ): Result[Any] = { - runRaw0(args, allowPositional, allowRepeats) match { + runRaw0(args, allowPositional, allowRepeats, nameMapper) match { case Left(err) => err case Right((main, res)) => res } } + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def runRaw0( + args: Seq[String], + allowPositional: Boolean, + allowRepeats: Boolean, + ): Either[Result.Failure.Early, (MainData[_, B], Result[Any])] = runRaw0( + args, + allowPositional, + allowRepeats, + Util.kebabCaseNameMapper + ) + def runRaw0( args: Seq[String], allowPositional: Boolean = false, - allowRepeats: Boolean = false + allowRepeats: Boolean = false, + nameMapper: String => Option[String] = Util.kebabCaseNameMapper ): Either[Result.Failure.Early, (MainData[_, B], Result[Any])] = { - for (tuple <- Invoker.runMains(mains, args, allowPositional, allowRepeats)) yield { + for (tuple <- Invoker.runMains(mains, args, allowPositional, allowRepeats, nameMapper)) yield { val (errMsg, res) = tuple (errMsg, res) } @@ -177,22 +263,32 @@ class ParserForMethods[B](val mains: MethodMains[B]) { object ParserForClass extends ParserForClassCompanionVersionSpecific class ParserForClass[T](val main: MainData[T, Any], val companion: () => Any) extends TokensReader.Class[T] { + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def helpText( + totalWidth: Int, + docsOnNewLine: Boolean, + customName: String, + customDoc: String, + sorted: Boolean): String = helpText(totalWidth, docsOnNewLine, customName, customDoc, sorted, Util.kebabCaseNameMapper) + def helpText( totalWidth: Int = 100, docsOnNewLine: Boolean = false, customName: String = null, customDoc: String = null, - sorted: Boolean = true + sorted: Boolean = true, + nameMapper: String => Option[String] = Util.kebabCaseNameMapper ): String = { Renderer.formatMainMethodSignature( main, 0, totalWidth, - Renderer.getLeftColWidth(main.renderedArgSigs), + Renderer.getLeftColWidth(main.renderedArgSigs, nameMapper), docsOnNewLine, Option(customName), Option(customDoc), - sorted + sorted, + nameMapper ) } @@ -204,6 +300,31 @@ class ParserForClass[T](val main: MainData[T, Any], val companion: () => Any) customDoc: String ): String = helpText(totalWidth, docsOnNewLine, customName, customDoc, sorted = true) + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def constructOrExit( + args: Seq[String], + allowPositional: Boolean, + allowRepeats: Boolean, + stderr: PrintStream, + totalWidth: Int, + printHelpOnExit: Boolean, + docsOnNewLine: Boolean, + autoPrintHelpAndExit: Option[(Int, PrintStream)], + customName: String, + customDoc: String): T = constructOrExit( + args, + allowPositional, + allowRepeats, + stderr, + totalWidth, + printHelpOnExit, + docsOnNewLine, + autoPrintHelpAndExit, + customName, + customDoc, + Util.kebabCaseNameMapper + ) + def constructOrExit( args: Seq[String], allowPositional: Boolean = false, @@ -214,7 +335,8 @@ class ParserForClass[T](val main: MainData[T, Any], val companion: () => Any) docsOnNewLine: Boolean = false, autoPrintHelpAndExit: Option[(Int, PrintStream)] = Some((0, System.out)), customName: String = null, - customDoc: String = null + customDoc: String = null, + nameMapper: String => Option[String] = Util.kebabCaseNameMapper ): T = { constructEither( args, @@ -225,7 +347,8 @@ class ParserForClass[T](val main: MainData[T, Any], val companion: () => Any) docsOnNewLine, autoPrintHelpAndExit, customName, - customDoc + customDoc, + nameMapper ) match { case Left(msg) => stderr.println(msg) @@ -234,6 +357,29 @@ class ParserForClass[T](val main: MainData[T, Any], val companion: () => Any) } } + def constructOrThrow( + args: Seq[String], + allowPositional: Boolean, + allowRepeats: Boolean, + totalWidth: Int, + printHelpOnExit: Boolean, + docsOnNewLine: Boolean, + autoPrintHelpAndExit: Option[(Int, PrintStream)], + customName: String, + customDoc: String, + ): T = constructOrThrow( + args, + allowPositional, + allowRepeats, + totalWidth, + printHelpOnExit, + docsOnNewLine, + autoPrintHelpAndExit, + customName, + customDoc, + Util.kebabCaseNameMapper + ) + def constructOrThrow( args: Seq[String], allowPositional: Boolean = false, @@ -243,7 +389,8 @@ class ParserForClass[T](val main: MainData[T, Any], val companion: () => Any) docsOnNewLine: Boolean = false, autoPrintHelpAndExit: Option[(Int, PrintStream)] = Some((0, System.out)), customName: String = null, - customDoc: String = null + customDoc: String = null, + nameMapper: String => Option[String] = Util.kebabCaseNameMapper ): T = { constructEither( args, @@ -254,13 +401,37 @@ class ParserForClass[T](val main: MainData[T, Any], val companion: () => Any) docsOnNewLine, autoPrintHelpAndExit, customName, - customDoc + customDoc, + nameMapper ) match { case Left(msg) => throw new Exception(msg) case Right(v) => v } } + def constructEither( + args: Seq[String], + allowPositional: Boolean, + allowRepeats: Boolean, + totalWidth: Int, + printHelpOnExit: Boolean, + docsOnNewLine: Boolean, + autoPrintHelpAndExit: Option[(Int, PrintStream)], + customName: String, + customDoc: String, + sorted: Boolean, + ): Either[String, T] = constructEither( + args, + allowPositional, + allowRepeats, + totalWidth, + printHelpOnExit, + docsOnNewLine, + autoPrintHelpAndExit, + customName, + customDoc, + sorted, + ) def constructEither( args: Seq[String], allowPositional: Boolean = false, @@ -271,13 +442,14 @@ class ParserForClass[T](val main: MainData[T, Any], val companion: () => Any) autoPrintHelpAndExit: Option[(Int, PrintStream)] = Some((0, System.out)), customName: String = null, customDoc: String = null, - sorted: Boolean = true + sorted: Boolean = true, + nameMapper: String => Option[String] = Util.kebabCaseNameMapper ): Either[String, T] = { if (autoPrintHelpAndExit.nonEmpty && args.take(1) == Seq("--help")) { val (exitCode, outputStream) = autoPrintHelpAndExit.get outputStream.println(helpText(totalWidth, docsOnNewLine, customName, customDoc, sorted)) Compat.exit(exitCode) - } else constructRaw(args, allowPositional, allowRepeats) match { + } else constructRaw(args, allowPositional, allowRepeats, nameMapper) match { case Result.Success(v) => Right(v) case f: Result.Failure => Left( @@ -289,7 +461,8 @@ class ParserForClass[T](val main: MainData[T, Any], val companion: () => Any) docsOnNewLine, Option(customName), Option(customDoc), - sorted + sorted, + nameMapper ) ) } @@ -305,7 +478,8 @@ class ParserForClass[T](val main: MainData[T, Any], val companion: () => Any) docsOnNewLine: Boolean, autoPrintHelpAndExit: Option[(Int, PrintStream)], customName: String, - customDoc: String + customDoc: String, + nameMapper: String => Option[String] ): Either[String, T] = constructEither( args, allowPositional, @@ -316,14 +490,27 @@ class ParserForClass[T](val main: MainData[T, Any], val companion: () => Any) autoPrintHelpAndExit, customName, customDoc, - sorted = true + sorted = true, + nameMapper = nameMapper + ) + + def constructRaw( + args: Seq[String], + allowPositional: Boolean, + allowRepeats: Boolean, + ): Result[T] = constructRaw( + args, + allowPositional, + allowRepeats, + nameMapper = Util.kebabCaseNameMapper ) def constructRaw( args: Seq[String], allowPositional: Boolean = false, - allowRepeats: Boolean = false + allowRepeats: Boolean = false, + nameMapper: String => Option[String] = Util.kebabCaseNameMapper ): Result[T] = { - Invoker.construct[T](this, args, allowPositional, allowRepeats) + Invoker.construct[T](this, args, allowPositional, allowRepeats, nameMapper) } } diff --git a/mainargs/src/Renderer.scala b/mainargs/src/Renderer.scala index b70fb01..bbde53b 100644 --- a/mainargs/src/Renderer.scala +++ b/mainargs/src/Renderer.scala @@ -5,60 +5,69 @@ import scala.math object Renderer { - def getLeftColWidth(items: Seq[ArgSig]) = { + def getLeftColWidth(items: Seq[ArgSig]): Int = getLeftColWidth(items, Util.kebabCaseNameMapper) + def getLeftColWidth(items: Seq[ArgSig], nameMapper: String => Option[String]): Int = { if (items.isEmpty) 0 - else items.map(renderArgShort(_).length).max + else items.map(renderArgShort(_, nameMapper).length).max } val newLine = System.lineSeparator() def normalizeNewlines(s: String) = s.replace("\r", "").replace("\n", newLine) - def renderArgShort(arg: ArgSig) = arg.reader match { + def renderArgShort(arg: ArgSig): String = renderArgShort(arg, Util.nullNameMapper) + + def renderArgShort(arg: ArgSig, nameMapper: String => Option[String]): String = arg.reader match { case r: TokensReader.Flag => val shortPrefix = arg.shortName.map(c => s"-$c") - val nameSuffix = arg.name.map(s => s"--$s") + val nameSuffix = arg.longName(nameMapper).map(s => s"--$s") (shortPrefix ++ nameSuffix).mkString(" ") case r: TokensReader.Simple[_] => val shortPrefix = arg.shortName.map(c => s"-$c") val typeSuffix = s"<${r.shortName}>" - val nameSuffix = if (arg.positional) arg.name else arg.name.map(s => s"--$s") + val nameSuffix = if (arg.positional) arg.longName(nameMapper) else arg.longName(nameMapper).map(s => s"--$s") (shortPrefix ++ nameSuffix ++ Seq(typeSuffix)).mkString(" ") case r: TokensReader.Leftover[_, _] => - s"${arg.name.get} <${r.shortName}>..." + s"${arg.longName(nameMapper).get} <${r.shortName}>..." } /** * Returns a `Some[string]` with the sortable string or a `None` if it is an leftover. */ - private def sortableName(arg: ArgSig): Option[String] = arg match { + private def sortableName(arg: ArgSig, nameMapper: String => Option[String]): Option[String] = arg match { case arg: ArgSig if arg.reader.isLeftover => None case a: ArgSig => - a.shortName.map(_.toString).orElse(a.name).orElse(Some("")) + a.shortName.map(_.toString).orElse(a.longName(nameMapper)).orElse(Some("")) case a: ArgSig => - a.name.orElse(Some("")) + a.longName(nameMapper) } object ArgOrd extends math.Ordering[ArgSig] { override def compare(x: ArgSig, y: ArgSig): Int = - (sortableName(x), sortableName(y)) match { + (sortableName(x, Util.nullNameMapper), sortableName(y, Util.nullNameMapper)) match { case (None, None) => 0 // don't sort leftovers case (None, Some(_)) => 1 // keep left overs at the end case (Some(_), None) => -1 // keep left overs at the end case (Some(l), Some(r)) => l.compare(r) } } + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def renderArg( + arg: ArgSig, + leftOffset: Int, + wrappedWidth: Int): (String, String) = renderArg(arg, leftOffset, wrappedWidth, Util.kebabCaseNameMapper) def renderArg( arg: ArgSig, leftOffset: Int, - wrappedWidth: Int + wrappedWidth: Int, + nameMapper: String => Option[String] ): (String, String) = { val wrapped = softWrap(arg.doc.getOrElse(""), leftOffset, wrappedWidth - leftOffset) - (renderArgShort(arg), wrapped) + (renderArgShort(arg, nameMapper), wrapped) } def formatMainMethods( @@ -67,14 +76,24 @@ object Renderer { docsOnNewLine: Boolean, customNames: Map[String, String], customDocs: Map[String, String], - sorted: Boolean + sorted: Boolean, + ): String = formatMainMethods(mainMethods, totalWidth, docsOnNewLine, customNames, customDocs, sorted, Util.kebabCaseNameMapper) + + def formatMainMethods( + mainMethods: Seq[MainData[_, _]], + totalWidth: Int, + docsOnNewLine: Boolean, + customNames: Map[String, String], + customDocs: Map[String, String], + sorted: Boolean, + nameMapper: String => Option[String] ): String = { val flattenedAll: Seq[ArgSig] = mainMethods.map(_.flattenedArgSigs) .flatten .map(_._1) - val leftColWidth = getLeftColWidth(flattenedAll) + val leftColWidth = getLeftColWidth(flattenedAll, nameMapper) mainMethods match { case Seq() => "" case Seq(main) => @@ -84,9 +103,10 @@ object Renderer { totalWidth, leftColWidth, docsOnNewLine, - customNames.get(main.name), - customDocs.get(main.name), - sorted + customNames.get(main.name(nameMapper)), + customDocs.get(main.name(nameMapper)), + sorted, + nameMapper ) case _ => val methods = @@ -97,9 +117,10 @@ object Renderer { totalWidth, leftColWidth, docsOnNewLine, - customNames.get(main.name), - customDocs.get(main.name), - sorted + customNames.get(main.name(nameMapper)), + customDocs.get(main.name(nameMapper)), + sorted, + nameMapper ) normalizeNewlines( @@ -116,14 +137,15 @@ object Renderer { totalWidth: Int, docsOnNewLine: Boolean, customNames: Map[String, String], - customDocs: Map[String, String] + customDocs: Map[String, String], ): String = formatMainMethods( mainMethods, totalWidth, docsOnNewLine, customNames, customDocs, - sorted = true + sorted = true, + Util.kebabCaseNameMapper ) def formatMainMethodSignature( @@ -134,7 +156,8 @@ object Renderer { docsOnNewLine: Boolean, customName: Option[String], customDoc: Option[String], - sorted: Boolean + sorted: Boolean, + nameMapper: String => Option[String] ): String = { val argLeftCol = if (docsOnNewLine) leftIndent + 8 else leftColWidth + leftIndent + 2 + 2 @@ -143,7 +166,7 @@ object Renderer { if (sorted) main.renderedArgSigs.sorted(ArgOrd) else main.renderedArgSigs - val args = sortedArgs.map(renderArg(_, argLeftCol, totalWidth)) + val args = sortedArgs.map(renderArg(_, argLeftCol, totalWidth, nameMapper)) val leftIndentStr = " " * leftIndent @@ -163,7 +186,7 @@ object Renderer { case Some(d) => newLine + leftIndentStr + softWrap(d, leftIndent, totalWidth) case None => "" } - s"""$leftIndentStr${customName.getOrElse(main.name)}$mainDocSuffix + s"""$leftIndentStr${customName.getOrElse(main.name(nameMapper))}$mainDocSuffix |${argStrings.map(_ + newLine).mkString}""".stripMargin } @@ -184,7 +207,29 @@ object Renderer { docsOnNewLine, customName, customDoc, - sorted = true + sorted = true, + ) + + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def formatMainMethodSignature( + main: MainData[_, _], + leftIndent: Int, + totalWidth: Int, + leftColWidth: Int, + docsOnNewLine: Boolean, + customName: Option[String], + customDoc: Option[String], + sorted: Boolean + ): String = formatMainMethodSignature( + main, + leftIndent, + totalWidth, + leftColWidth, + docsOnNewLine, + customName, + customDoc, + sorted, + Util.kebabCaseNameMapper ) def softWrap(s: String, leftOffset: Int, maxWidth: Int) = { @@ -225,6 +270,7 @@ object Renderer { s"Did you mean `${token.drop(2)}` instead of `$token`?" } + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") def renderResult( main: MainData[_, _], result: Result.Failure, @@ -233,12 +279,33 @@ object Renderer { docsOnNewLine: Boolean, customName: Option[String], customDoc: Option[String], - sorted: Boolean + sorted: Boolean, + ): String = renderResult( + main, + result, + totalWidth, + printHelpOnError, + docsOnNewLine, + customName, + customDoc, + sorted, + Util.kebabCaseNameMapper + ) + def renderResult( + main: MainData[_, _], + result: Result.Failure, + totalWidth: Int, + printHelpOnError: Boolean, + docsOnNewLine: Boolean, + customName: Option[String], + customDoc: Option[String], + sorted: Boolean, + nameMapper: String => Option[String] ): String = { def expectedMsg() = { if (printHelpOnError) { - val leftColWidth = getLeftColWidth(main.renderedArgSigs) + val leftColWidth = getLeftColWidth(main.renderedArgSigs, nameMapper) "Expected Signature: " + Renderer.formatMainMethodSignature( main, @@ -248,7 +315,8 @@ object Renderer { docsOnNewLine, customName, customDoc, - sorted + sorted, + nameMapper ) } else "" } @@ -264,7 +332,7 @@ object Renderer { val missingStr = if (missing.isEmpty) "" else { - val chunks = missing.map(renderArgShort(_)) + val chunks = missing.map(renderArgShort(_, nameMapper)) val argumentsStr = pluralize("argument", chunks.length) s"Missing $argumentsStr: ${chunks.mkString(" ")}" + Renderer.newLine @@ -285,7 +353,7 @@ object Renderer { val lines = for ((sig, options) <- duplicate) yield { - s"Duplicate arguments for ${renderArgShort(sig)}: " + + s"Duplicate arguments for ${renderArgShort(sig, nameMapper)}: " + options.map(Util.literalize(_)).mkString(" ") + Renderer.newLine } @@ -295,7 +363,7 @@ object Renderer { val incompleteStr = incomplete match { case None => "" case Some(sig) => - s"Incomplete argument ${renderArgShort(sig)} is missing a corresponding value" + + s"Incomplete argument ${renderArgShort(sig, nameMapper)} is missing a corresponding value" + Renderer.newLine } @@ -309,12 +377,12 @@ object Renderer { val thingies = x.map { case Result.ParamError.Failed(p, vs, errMsg) => val literalV = vs.map(Util.literalize(_)).mkString(" ") - s"Invalid argument ${renderArgShort(p)} failed to parse $literalV due to $errMsg" + s"Invalid argument ${renderArgShort(p, nameMapper)} failed to parse $literalV due to $errMsg" case Result.ParamError.Exception(p, vs, ex) => val literalV = vs.map(Util.literalize(_)).mkString(" ") - s"Invalid argument ${renderArgShort(p)} failed to parse $literalV due to $ex" + s"Invalid argument ${renderArgShort(p, nameMapper)} failed to parse $literalV due to $ex" case Result.ParamError.DefaultFailed(p, ex) => - s"Invalid argument ${renderArgShort(p)}'s default value failed to evaluate with $ex" + s"Invalid argument ${renderArgShort(p, nameMapper)}'s default value failed to evaluate with $ex" } Renderer.normalizeNewlines( diff --git a/mainargs/src/TokenGrouping.scala b/mainargs/src/TokenGrouping.scala index 0b4154c..dd3feda 100644 --- a/mainargs/src/TokenGrouping.scala +++ b/mainargs/src/TokenGrouping.scala @@ -5,20 +5,32 @@ import scala.annotation.tailrec case class TokenGrouping[B](remaining: List[String], grouped: Map[ArgSig, Seq[String]]) object TokenGrouping { + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") def groupArgs[B]( flatArgs0: Seq[String], argSigs: Seq[(ArgSig, TokensReader.Terminal[_])], allowPositional: Boolean, allowRepeats: Boolean, - allowLeftover: Boolean + allowLeftover: Boolean, ): Result[TokenGrouping[B]] = { - val positionalArgSigs = argSigs.collect { + groupArgs(flatArgs0, argSigs, allowPositional, allowRepeats, allowLeftover, _ => None) + } + + def groupArgs[B]( + flatArgs0: Seq[String], + argSigs: Seq[(ArgSig, TokensReader.Terminal[_])], + allowPositional: Boolean, + allowRepeats: Boolean, + allowLeftover: Boolean, + nameMapper: String => Option[String] + ): Result[TokenGrouping[B]] = { + val positionalArgSigs: Seq[ArgSig] = argSigs.collect { case (a, r: TokensReader.Simple[_]) if allowPositional | a.positional => a } val flatArgs = flatArgs0.toList - def makeKeywordArgMap(getNames: ArgSig => Iterable[String]) = argSigs + def makeKeywordArgMap(getNames: ArgSig => Iterable[String]): Map[String, ArgSig] = argSigs .collect { case (a, r: TokensReader.Simple[_]) if !a.positional => a case (a, r: TokensReader.Flag) => a @@ -36,7 +48,7 @@ object TokenGrouping { .flatten .toMap[Char, ArgSig] - lazy val longKeywordArgMap = makeKeywordArgMap(_.name.map("--" + _)) + lazy val longKeywordArgMap = makeKeywordArgMap(x => x.mappedName(nameMapper).map("--"+ _ ) ++ x.longName(Util.nullNameMapper).map("--" + _)) def parseCombinedShortTokens(current: Map[ArgSig, Vector[String]], head: String, diff --git a/mainargs/src/TokensReader.scala b/mainargs/src/TokensReader.scala index 42449d0..b03bd5a 100644 --- a/mainargs/src/TokensReader.scala +++ b/mainargs/src/TokensReader.scala @@ -223,15 +223,15 @@ object TokensReader { object ArgSig { def create[T, B](name0: String, arg: mainargs.arg, defaultOpt: Option[B => T]) (implicit tokensReader: TokensReader[T]): ArgSig = { - val nameOpt = scala.Option(arg.name).orElse(if (name0.length == 1 || arg.noDefaultName) None - else Some(name0)) val shortOpt = arg.short match { case '\u0000' => if (name0.length != 1 || arg.noDefaultName) None else Some(name0(0)); case c => Some(c) } + val docOpt = scala.Option(arg.doc) - ArgSig( - nameOpt, + new ArgSig( + if (arg.noDefaultName || name0.length == 1) None else Some(name0), + scala.Option(arg.name), shortOpt, docOpt, defaultOpt.asInstanceOf[Option[Any => Any]], @@ -245,23 +245,91 @@ object ArgSig { case r: TokensReader.Terminal[T] => Seq((x, r)) case cls: TokensReader.Class[_] => cls.main.argSigs0.flatMap(flatten(_)) } + + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def apply(unMappedName: Option[String], + shortName: Option[Char], + doc: Option[String], + default: Option[Any => Any], + reader: TokensReader[_], + positional: Boolean, + hidden: Boolean) = { + + new ArgSig(unMappedName, unMappedName, shortName, doc, default, reader, positional, hidden) + } + + def unapply(a: ArgSig) = Option( + (a.unMappedName, a.shortName, a.doc, a.default, a.reader, a.positional, a.hidden) + ) } /** * Models what is known by the router about a single argument: that it has - * a [[name]], a human-readable [[typeString]] describing what the type is + * a [[longName]], a human-readable [[typeString]] describing what the type is * (just for logging and reading, not a replacement for a `TypeTag`) and * possible a function that can compute its default value */ -case class ArgSig( - name: Option[String], - shortName: Option[Char], - doc: Option[String], - default: Option[Any => Any], - reader: TokensReader[_], - positional: Boolean, - hidden: Boolean -) +class ArgSig private[mainargs] (val defaultLongName: Option[String], + val argName: Option[String], + val shortName: Option[Char], + val doc: Option[String], + val default: Option[Any => Any], + val reader: TokensReader[_], + val positional: Boolean, + val hidden: Boolean +) extends Product with Serializable with Equals{ + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def name = defaultLongName + override def canEqual(that: Any): Boolean = true + + override def hashCode(): Int = ArgSig.unapply(this).hashCode() + override def equals(o: Any): Boolean = o match { + case other: ArgSig => ArgSig.unapply(this) == ArgSig.unapply(other) + case _ => false + } + + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def this(unmappedName: Option[String], + shortName: Option[Char], + doc: Option[String], + default: Option[Any => Any], + reader: TokensReader[_], + positional: Boolean, + hidden: Boolean) = { + this(unmappedName, unmappedName, shortName, doc, default, reader, positional, hidden) + } + + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def copy(unMappedName: Option[String] = this.unMappedName, + shortName: Option[Char] = this.shortName, + doc: Option[String] = this.doc, + default: Option[Any => Any] = this.default, + reader: TokensReader[_] = this.reader, + positional: Boolean = this.positional, + hidden: Boolean = this.hidden) = { + ArgSig(unMappedName, shortName, doc, default, reader, positional, hidden) + } + + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def productArity = 9 + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def productElement(n: Int) = n match{ + case 0 => defaultLongName + case 1 => argName + case 2 => shortName + case 3 => doc + case 4 => default + case 5 => reader + case 6 => positional + case 7 => hidden + } + + def unMappedName: Option[String] = argName.orElse(defaultLongName) + def longName(nameMapper: String => Option[String]): Option[String] = argName.orElse(mappedName(nameMapper)).orElse(defaultLongName) + def mappedName(nameMapper: String => Option[String]): Option[String] = + if (argName.isDefined) None else defaultLongName.flatMap(nameMapper) +} + case class MethodMains[B](value: Seq[MainData[Any, B]], base: () => B) @@ -274,12 +342,57 @@ case class MethodMains[B](value: Seq[MainData[Any, B]], base: () => B) * instead, which provides a nicer API to call it that mimmicks the API of * calling a Scala method. */ -case class MainData[T, B]( - name: String, - argSigs0: Seq[ArgSig], - doc: Option[String], - invokeRaw: (B, Seq[Any]) => T -) { +class MainData[T, B] private[mainargs] ( + val mainName: Option[String], + val defaultName: String, + val argSigs0: Seq[ArgSig], + val doc: Option[String], + val invokeRaw: (B, Seq[Any]) => T +) extends Product with Serializable with Equals{ + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def name = mainName.getOrElse(defaultName) + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def productArity = 5 + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def productElement(n: Int) = n match{ + case 0 => mainName + case 1 => defaultName + case 2 => argSigs0 + case 3 => doc + case 4 => invokeRaw + } + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def copy(name: String = this.unmappedName, + argSigs0: Seq[ArgSig] = this.argSigs0, + doc: Option[String] = this.doc, + invokeRaw: (B, Seq[Any]) => T = this.invokeRaw) = MainData( + name, argSigs0, doc, invokeRaw + ) + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def this(name: String, + argSigs0: Seq[ArgSig], + doc: Option[String], + invokeRaw: (B, Seq[Any]) => T) = this( + Some(name), name, argSigs0, doc, invokeRaw + ) + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + override def hashCode(): Int = MainData.unapply(this).hashCode() + + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + override def equals(obj: Any): Boolean = obj match{ + case x: MainData[_, _] => MainData.unapply(x) == MainData.unapply(this) + case _ => false + } + + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + override def canEqual(that: Any): Boolean = true + + def unmappedName: String = mainName.getOrElse(defaultName) + + def name(nameMapper: String => Option[String]) = mainName.orElse(mappedName(nameMapper)).getOrElse(defaultName) + def mappedName(nameMapper: String => Option[String]): Option[String] = + if (mainName.isDefined) None + else nameMapper(defaultName) val flattenedArgSigs: Seq[(ArgSig, TokensReader.Terminal[_])] = argSigs0.iterator.flatMap[(ArgSig, TokensReader.Terminal[_])](ArgSig.flatten(_)).toVector @@ -289,14 +402,24 @@ case class MainData[T, B]( } object MainData { + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def unapply[T, B](x: MainData[T, B]) = Option((x.mainName, x.defaultName, x.argSigs0, x.doc, x.invokeRaw)) + @deprecated("Binary Compatibility Shim", "mainargs 0.6.0") + def apply[T, B](name: String, + argSigs0: Seq[ArgSig], + doc: Option[String], + invokeRaw: (B, Seq[Any]) => T) = { + new MainData(Some(name), name, argSigs0, doc, invokeRaw) + } def create[T, B]( methodName: String, main: mainargs.main, argSigs: Seq[ArgSig], invokeRaw: (B, Seq[Any]) => T ) = { - MainData( - Option(main.name).getOrElse(methodName), + new MainData( + Option(main.name), + methodName, argSigs, Option(main.doc), invokeRaw diff --git a/mainargs/src/Util.scala b/mainargs/src/Util.scala index b89bebc..d4e894f 100644 --- a/mainargs/src/Util.scala +++ b/mainargs/src/Util.scala @@ -3,6 +3,44 @@ package mainargs import scala.annotation.{switch, tailrec} object Util { + def nullNameMapper(s: String): Option[String] = None + + def kebabCaseNameMapper(s: String): Option[String] = { + baseNameMapper(s, '-') + } + def snakeCaseNameMapper(s: String): Option[String] = { + baseNameMapper(s, '_') + } + + def baseNameMapper(s: String, sep: Char): Option[String] = { + val chars = new collection.mutable.StringBuilder + // 'D' -> digit + // 'U' -> uppercase + // 'L' -> lowercase + // 'O' -> other + var state = ' ' + + for (c <- s) { + if (c.isDigit){ + if (state == 'L' || state == 'U') chars.append(sep) + chars.append(c) + state = 'D' + } else if (c.isUpper) { + if (state == 'L' || state == 'D') chars.append(sep) + chars.append(c.toLower) + state = 'U' + } else if (c.isLower){ + chars.append(c) + state = 'L' + } else { + state = 'O' + chars.append(c) + } + } + + Some(chars.toString()) + } + def literalize(s: IndexedSeq[Char], unicode: Boolean = false) = { val sb = new StringBuilder sb.append('"') diff --git a/mainargs/test/src/Checker.scala b/mainargs/test/src/Checker.scala index 6a4e4d3..a1788e6 100644 --- a/mainargs/test/src/Checker.scala +++ b/mainargs/test/src/Checker.scala @@ -1,9 +1,9 @@ package mainargs -class Checker[B](val parser: ParserForMethods[B], allowPositional: Boolean) { +class Checker[B](val parser: ParserForMethods[B], allowPositional: Boolean, nameMapper: String => Option[String] = Util.kebabCaseNameMapper) { val mains = parser.mains def parseInvoke(input: List[String]) = { - parser.runRaw(input, allowPositional = allowPositional) + parser.runRaw(input, allowPositional = allowPositional, nameMapper = nameMapper) } def apply[T](input: List[String], expected: Result[T]) = { val result = parseInvoke(input) diff --git a/mainargs/test/src/CoreTests.scala b/mainargs/test/src/CoreTests.scala index 03e2aae..df6eaeb 100644 --- a/mainargs/test/src/CoreTests.scala +++ b/mainargs/test/src/CoreTests.scala @@ -52,7 +52,7 @@ class CoreTests(allowPositional: Boolean) extends TestSuite { parsed ==> expected } test("basicModelling") { - val names = check.mains.value.map(_.name) + val names = check.mains.value.map(_.name(Util.nullNameMapper)) assert( names == diff --git a/mainargs/test/src/DashedArgumentName.scala b/mainargs/test/src/DashedArgumentName.scala index 8b49e69..f21a812 100644 --- a/mainargs/test/src/DashedArgumentName.scala +++ b/mainargs/test/src/DashedArgumentName.scala @@ -6,27 +6,103 @@ object DashedArgumentName extends TestSuite { object Base { @main def `opt-for-18+name`(`opt-for-18+arg`: Boolean) = `opt-for-18+arg` + @main def `opt-for-29+name`(`opt-for-29+arg`: Boolean) = `opt-for-29+arg` + + @main + def camelOptFor29Name(camelOptFor29Arg: Boolean) = camelOptFor29Arg + + @main(name = "camelOptFor29NameForce") + def camelOptFor29NameForce(@arg(name = "camelOptFor29ArgForce") camelOptFor29ArgForce: Boolean) = camelOptFor29ArgForce } val check = new Checker(ParserForMethods(Base), allowPositional = true) + val snakeCaseCheck = new Checker(ParserForMethods(Base), allowPositional = true, nameMapper = Util.snakeCaseNameMapper) val tests = Tests { - test - check( - List("opt-for-18+name", "--opt-for-18+arg", "true"), - Result.Success(true) - ) - test - check( - List("opt-for-18+name", "--opt-for-18+arg", "false"), - Result.Success(false) - ) - test - check( - List("opt-for-29+name", "--opt-for-29+arg", "true"), - Result.Success(true) - ) - test - check( - List("opt-for-29+name", "--opt-for-29+arg", "false"), - Result.Success(false) - ) + test("backticked") { + test - check( + List("opt-for-18+name", "--opt-for-18+arg", "true"), + Result.Success(true) + ) + test - check( + List("opt-for-18+name", "--opt-for-18+arg", "false"), + Result.Success(false) + ) + test - check( + List("opt-for-29+name", "--opt-for-29+arg", "true"), + Result.Success(true) + ) + test - check( + List("opt-for-29+name", "--opt-for-29+arg", "false"), + Result.Success(false) + ) + } + test("camelKebabNameMapped") { + test("mapped") - check( + List("camel-opt-for-29-name", "--camel-opt-for-29-arg", "false"), + Result.Success(false) + ) + + // Make sure we continue to support un-mapped names for backwards compatibility + test("backwardsCompatUnmapped") - check( + List("camelOptFor29Name", "--camelOptFor29Arg", "false"), + Result.Success(false) + ) + + test("explicitNameUnmapped") - check( + List("camelOptFor29NameForce", "--camelOptFor29ArgForce", "false"), + Result.Success(false) + ) + + // For names given explicitly via `main(name = ...)` or `arg(name = ...)`, we + // do not use a name mapper, since we assume the user would provide the exact + // name they want. + test("explicitMainNameMappedFails") - check( + List("camel-opt-for-29-name-force", "--camel-opt-for-29-arg-force", "false"), + Result.Failure.Early.UnableToFindSubcommand( + List("opt-for-18+name", "opt-for-29+name", "camel-opt-for-29-name", "camelOptFor29NameForce"), + "camel-opt-for-29-name-force" + ) + ) + test("explicitArgNameMappedFails") - check( + List("camelOptFor29NameForce", "--camel-opt-for-29-arg-force", "false"), + Result.Failure.MismatchedArguments( + Vector( + new ArgSig( + Some("camelOptFor29ArgForce"), + Some("camelOptFor29ArgForce"), + None, + None, + None, + mainargs.TokensReader.BooleanRead, + positional = false, + hidden = false + ) + ), + List("--camel-opt-for-29-arg-force", "false"), + List(), + None + ) + + ) + } + test("camelSnakeNameMapped") { + test("mapped") - snakeCaseCheck( + List("camel_opt_for_29_name", "--camel_opt_for_29_arg", "false"), + Result.Success(false) + ) + + // Make sure we continue to support un-mapped names for backwards compatibility + test("backwardsCompatUnmapped") - check( + List("camelOptFor29Name", "--camelOptFor29Arg", "false"), + Result.Success(false) + ) + + test("explicitNameUnmapped") - check( + List("camelOptFor29NameForce", "--camelOptFor29ArgForce", "false"), + Result.Success(false) + ) + } } } diff --git a/mainargs/test/src/EqualsSyntaxTests.scala b/mainargs/test/src/EqualsSyntaxTests.scala index 461556c..b4a34c7 100644 --- a/mainargs/test/src/EqualsSyntaxTests.scala +++ b/mainargs/test/src/EqualsSyntaxTests.scala @@ -8,7 +8,7 @@ object EqualsSyntaxTests extends TestSuite { def run( @arg(short = 'f', doc = "String to print repeatedly") foo: String, - @arg(name = "my-num", doc = "How many times to print string") + @arg(doc = "How many times to print string") myNum: Int = 2, @arg(doc = "Example flag") bool: Flag diff --git a/mill b/mill index cb1ee32..0c5078a 100755 --- a/mill +++ b/mill @@ -7,7 +7,7 @@ set -e if [ -z "${DEFAULT_MILL_VERSION}" ] ; then - DEFAULT_MILL_VERSION=0.11.0 + DEFAULT_MILL_VERSION=0.11.6 fi if [ -z "$MILL_VERSION" ] ; then diff --git a/readme.md b/readme.md index 668ed9b..412a563 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,4 @@ -# mainargs 0.5.4 +# mainargs 0.6.0 MainArgs is a small, dependency-free library for command line argument parsing in Scala. @@ -45,7 +45,7 @@ in its scripts, as well as for command-line parsing for the # Usage ```scala -ivy"com.lihaoyi::mainargs:0.5.4" +ivy"com.lihaoyi::mainargs:0.6.0" ``` ## Parsing Main Method Parameters @@ -61,7 +61,7 @@ object Main{ @main def run(@arg(short = 'f', doc = "String to print repeatedly") foo: String, - @arg(name = "my-num", doc = "How many times to print string") + @arg(doc = "How many times to print string") myNum: Int = 2, @arg(doc = "Example flag, can be passed without any value to become true") bool: Flag) = { @@ -145,7 +145,7 @@ object Main{ @main def foo(@arg(short = 'f', doc = "String to print repeatedly") foo: String, - @arg(name = "my-num", doc = "How many times to print string") + @arg(doc = "How many times to print string") myNum: Int = 2, @arg(doc = "Example flag") bool: Flag) = { @@ -185,7 +185,7 @@ object Main{ @main case class Config(@arg(short = 'f', doc = "String to print repeatedly") foo: String, - @arg(name = "my-num", doc = "How many times to print string") + @arg(doc = "How many times to print string") myNum: Int = 2, @arg(doc = "Example flag") bool: Flag) @@ -226,7 +226,7 @@ object Main{ @main case class Config(@arg(short = 'f', doc = "String to print repeatedly") foo: String, - @arg(name = "my-num", doc = "How many times to print string") + @arg(doc = "How many times to print string") myNum: Int = 2, @arg(doc = "Example flag") bool: Flag) @@ -400,7 +400,9 @@ customize your usage: - `name: String`: lets you specify the top-level name of `@main` method you are defining. If multiple `@main` methods are provided, this name controls the - sub-command name in the CLI + sub-command name in the CLI. If an explicit `name` not passed, both the + (typically) `camelCase` name of the Scala `def` as well as its `kebab-case` + equivalents will be accepted - `doc: String`: a documentation string used to provide additional information about the command. Normally printed below the command name in the help message @@ -408,7 +410,9 @@ customize your usage: ### @arg - `name: String`: lets you specify the long name of a CLI parameter, e.g. - `--foo`. Defaults to the name of the function parameter if not given + `--foo`. If an explicit `name` not passed, both the (typically) `camelCase` + name of the Scala method parameter as well as its `kebab-case` + equivalents will be accepted - `short: Char`: lets you specify the short name of a CLI parameter, e.g. `-f`. If not given, the argument can only be provided via its long name @@ -454,6 +458,11 @@ of useful configuration values: - `sorted: Boolean`: whether to sort the arguments alphabetically in the help text. Defaults to `true` +- `nameMapper: String => Option[String]`: how Scala `camelCase` names are mapping + to CLI command and flag names. Defaults to translation to `kebab-case`, but + you can pass in `mainargs.Util.snakeCaseNameMapper` for `snake_case` CLI names + or `mainargs.Util.nullNameMapper` to disable mapping. + ## Custom Argument Parsers If you want to parse arguments into types that are not provided by the library, @@ -623,6 +632,12 @@ command-line friendly tool. # Changelog +## 0.6.0 + +- Automatically map `camelCase` Scala method and argument names to `kebab-case` + CLI commands and flag names, with configurability by passing in custom + `nameMappers` [#101](https://github.com/com-lihaoyi/mainargs/pull/101) + ## 0.5.4 - Remove unnecessary PPrint dependency