├── .github ├── CODEOWNERS ├── workflows │ └── unittests.yaml └── logos │ └── fulcrumgenomics.svg ├── project ├── build.properties └── plugins.sbt ├── .gitignore ├── codecov.yml ├── version.sbt ├── LICENSE ├── src ├── test │ ├── java │ │ └── com │ │ │ └── fulcrumgenomics │ │ │ └── sopt │ │ │ └── cmdline │ │ │ ├── BadEnum.java │ │ │ └── GoodEnum.java │ └── scala │ │ └── com │ │ └── fulcrumgenomics │ │ └── sopt │ │ ├── cmdline │ │ ├── TestingClp.scala │ │ ├── TestGroup.scala │ │ ├── testing │ │ │ ├── missing │ │ │ │ └── CommandLinePrograms.scala │ │ │ ├── fields │ │ │ │ └── CommandLinePrograms.scala │ │ │ ├── simple │ │ │ │ └── CommandLinePrograms.scala │ │ │ └── clps │ │ │ │ └── CommandLinePrograms.scala │ │ ├── ClpGroupTest.scala │ │ ├── ClpReflectiveBuilderTest.scala │ │ ├── ClpArgumentDefinitionPrintingTest.scala │ │ └── ClpArgumentTest.scala │ │ ├── util │ │ ├── UnitSpec.scala │ │ ├── ParsingUtilTest.scala │ │ └── MarkDownProcessorTest.scala │ │ ├── SoptTest.scala │ │ └── parsing │ │ ├── ArgTokenCollatorTest.scala │ │ ├── OptionParserTest.scala │ │ └── ArgTokenizerTest.scala └── main │ ├── scala │ └── com │ │ └── fulcrumgenomics │ │ └── sopt │ │ ├── cmdline │ │ ├── SpecialArgumentsCollection.scala │ │ ├── ParseResult.scala │ │ ├── ValidationException.scala │ │ ├── ClpGroup.scala │ │ ├── CommandLineException.scala │ │ └── ClpArgumentDefinitionPrinting.scala │ │ ├── package.scala │ │ ├── parsing │ │ ├── OptionParsingExceptions.scala │ │ ├── OptionParser.scala │ │ ├── ArgTokenCollator.scala │ │ ├── ArgTokenizer.scala │ │ └── OptionLookup.scala │ │ ├── util │ │ ├── TermCode.scala │ │ ├── ParsingUtil.scala │ │ └── MarkDownProcessor.scala │ │ └── Sopt.scala │ └── java │ └── com │ └── fulcrumgenomics │ └── sopt │ └── cmdline │ ├── ClpAnnotation.java │ └── ArgAnnotation.java └── README.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @nh13 @tfenne 2 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.4 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .java-version 3 | target 4 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | patch: 4 | default: 5 | target: '80' 6 | project: 7 | default: 8 | target: auto 9 | -------------------------------------------------------------------------------- /version.sbt: -------------------------------------------------------------------------------- 1 | val gitHeadCommitSha = settingKey[String]("current git commit SHA") 2 | gitHeadCommitSha in ThisBuild := scala.sys.process.Process("git rev-parse --short HEAD").lineStream.head 3 | 4 | // *** IMPORTANT *** 5 | // One of the two "version" lines below needs to be uncommented. 6 | // version in ThisBuild := "1.2.0" // the release version 7 | version in ThisBuild := s"1.2.0-${gitHeadCommitSha.value}-SNAPSHOT" // the snapshot version 8 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | resolvers += Resolver.url("fix-sbt-plugin-releases", url("https://dl.bintray.com/sbt/sbt-plugin-releases"))(Resolver.ivyStylePatterns) 2 | 3 | addDependencyTreePlugin 4 | 5 | addSbtPlugin("com.typesafe.sbt" % "sbt-git" % "1.0.0") 6 | addSbtPlugin("com.github.gseitz" % "sbt-release" % "1.0.10") 7 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.2.0") 8 | addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.6.1") 9 | addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.3.1") 10 | addSbtPlugin("com.thoughtworks.sbt-api-mappings" % "sbt-api-mappings" % "3.0.0") 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2015-2016 Fulcrum Genomics LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/test/java/com/fulcrumgenomics/sopt/cmdline/BadEnum.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics.sopt.cmdline; 26 | 27 | public enum BadEnum { 28 | } 29 | -------------------------------------------------------------------------------- /src/test/java/com/fulcrumgenomics/sopt/cmdline/GoodEnum.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics.sopt.cmdline; 26 | 27 | public enum GoodEnum { 28 | GOOD, BOY 29 | } 30 | -------------------------------------------------------------------------------- /src/test/scala/com/fulcrumgenomics/sopt/cmdline/TestingClp.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics.sopt.cmdline 26 | 27 | /** A base class for some clp testing classes. */ 28 | class TestingClp 29 | -------------------------------------------------------------------------------- /src/test/scala/com/fulcrumgenomics/sopt/cmdline/TestGroup.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | package com.fulcrumgenomics.sopt.cmdline 25 | 26 | class TestGroup extends ClpGroup { 27 | val name: String = "Testing" 28 | val description: String = "Testing" 29 | } 30 | -------------------------------------------------------------------------------- /src/test/scala/com/fulcrumgenomics/sopt/util/UnitSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | package com.fulcrumgenomics.sopt.util 25 | 26 | import org.scalatest.{FlatSpec, Matchers} 27 | 28 | /** Base class for unit and integration testing */ 29 | class UnitSpec extends FlatSpec with Matchers 30 | -------------------------------------------------------------------------------- /src/test/scala/com/fulcrumgenomics/sopt/cmdline/testing/missing/CommandLinePrograms.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | package com.fulcrumgenomics.sopt.cmdline.testing.missing 25 | 26 | /** For testing the ability to find and filter classes with the CLP property */ 27 | 28 | abstract class CommandLineProgram 29 | 30 | class MissingPropertyTask extends CommandLineProgram 31 | -------------------------------------------------------------------------------- /src/main/scala/com/fulcrumgenomics/sopt/cmdline/SpecialArgumentsCollection.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | package com.fulcrumgenomics.sopt.cmdline 25 | 26 | import com.fulcrumgenomics.sopt._ 27 | 28 | /** 29 | * This collection is for arguments that require special treatment by the arguments parser itself. 30 | * It should not grow beyond a very short list. 31 | */ 32 | private[cmdline] final case class SpecialArgumentsCollection( 33 | @arg(flag = 'h', name = "help", doc = "Display the help message.", special = true) 34 | var help: Boolean = false, 35 | @arg(name = "version", doc = "Display the version number for this tool.", special = true) 36 | var version: Boolean = false 37 | ) 38 | -------------------------------------------------------------------------------- /src/main/scala/com/fulcrumgenomics/sopt/cmdline/ParseResult.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics.sopt.cmdline 26 | 27 | sealed abstract class ParseResult 28 | 29 | /** Class that indicates a successful parsing. */ 30 | case class ParseSuccess() extends ParseResult 31 | 32 | /** Class that indicates a help option was found. */ 33 | case class ParseHelp() extends ParseResult 34 | 35 | /** Class that indicates a version option was found. */ 36 | case class ParseVersion() extends ParseResult 37 | 38 | /** Class that indicates a failure in parsing. An exception and the remaining arguments, if applicable, are provied. */ 39 | case class ParseFailure(ex: Exception, remaining: Iterable[String]) extends ParseResult 40 | -------------------------------------------------------------------------------- /src/main/scala/com/fulcrumgenomics/sopt/cmdline/ValidationException.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | package com.fulcrumgenomics.sopt.cmdline 25 | 26 | /** 27 | * Exception class that is intended to be used to signal one or more validation errors in 28 | * command line program constructors, and provide useful messages back to the user. 29 | */ 30 | case class ValidationException(messages: List[String]) extends RuntimeException { 31 | /** Alternate constructor that takes var arg Strings and turns them into a List. */ 32 | def this(messages: String*) = this(messages.toList) 33 | 34 | /** Alternate constructor that takes any [[scala.Iterable]] of Strings and turns them into a List. */ 35 | def this(messages: Iterable[String]) = this(messages.toList) 36 | } 37 | -------------------------------------------------------------------------------- /src/test/scala/com/fulcrumgenomics/sopt/cmdline/ClpGroupTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | package com.fulcrumgenomics.sopt.cmdline 25 | 26 | import com.fulcrumgenomics.sopt.util.UnitSpec 27 | 28 | class ClpsGroupOne extends ClpGroup { 29 | val name: String = "AAAAA" 30 | val description: String = "Various pipeline programs." 31 | } 32 | 33 | class ClpsGroupTwo extends ClpGroup { 34 | val name: String = "BBBBB" 35 | val description: String = "Various pipeline programs." 36 | } 37 | class ClpGroupTest extends UnitSpec { 38 | 39 | "ClpGroup" should "sort groups by alphabetical ordering of name" in { 40 | val a = new ClpsGroupOne 41 | val b = new ClpsGroupTwo 42 | a.compareTo(b) should be < 0 43 | a.compareTo(a) shouldBe 0 44 | b.compareTo(a) should be > 0 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/scala/com/fulcrumgenomics/sopt/cmdline/ClpGroup.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics.sopt.cmdline 26 | 27 | /** 28 | * Trait for groups of CommandLinePrograms. 29 | */ 30 | trait ClpGroup extends Ordered[ClpGroup] { 31 | /** Gets the name of this program. **/ 32 | def name : String 33 | /** Gets the description of this program. **/ 34 | def description : String 35 | /** The rank of the program group relative to other program groups. */ 36 | def rank : Int = 1024 37 | /** Order groups by rank, then by name. */ 38 | override def compare(that: ClpGroup): Int = { 39 | if (this.rank != that.rank) this.rank.compareTo(that.rank) 40 | else this.name.compareTo(that.name) 41 | } 42 | } 43 | 44 | /** The program group for the command line programs. */ 45 | class Clps extends ClpGroup { 46 | val name: String = "Clps" 47 | val description: String = "Various command line programs." 48 | } 49 | -------------------------------------------------------------------------------- /src/test/scala/com/fulcrumgenomics/sopt/cmdline/testing/fields/CommandLinePrograms.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | package com.fulcrumgenomics.sopt.cmdline.testing.fields 25 | 26 | import com.fulcrumgenomics.commons.util.LogLevel 27 | 28 | /** For testing the ability to find and filter classes with the CLP property */ 29 | 30 | object Fields { 31 | type PathToSomething = java.nio.file.Path 32 | } 33 | class WithList(var list: List[_]) 34 | class WithIntList(var list: List[Int]) 35 | class WithJavaCollection(var list: java.util.Collection[_]) 36 | class WithJavaSet(var set: java.util.Set[_]) 37 | class WithOption(var v: Option[_]) 38 | class WithIntOption(var v: Option[Int]) 39 | class WithInt(var v: Int) 40 | class WithMap(var map: Map[_, _]) 41 | class WithPathToBamOption(var path: Option[Fields.PathToSomething]) 42 | class WithString(var s: String) 43 | class WithPathToBam(var path: Fields.PathToSomething) 44 | class WithStringParent(var s: String = "") 45 | class WithStringChild(var t: String) extends WithStringParent 46 | class WithEnum(var verbosity: LogLevel = LogLevel.Info) 47 | class SetClass(var set: Set[_] = Set.empty) 48 | class SeqClass(var seq: Seq[_] = Nil) 49 | -------------------------------------------------------------------------------- /src/test/scala/com/fulcrumgenomics/sopt/cmdline/testing/simple/CommandLinePrograms.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | package com.fulcrumgenomics.sopt.cmdline.testing.simple 25 | 26 | import com.fulcrumgenomics.sopt._ 27 | 28 | /** For testing the ability to find and filter classes with the CLP property */ 29 | 30 | abstract class CommandLineProgram 31 | 32 | @clp(description = "", hidden = false) abstract class NoOpCommandLineProgram extends CommandLineProgram 33 | 34 | @clp(description = "", hidden = false) class InClass extends NoOpCommandLineProgram 35 | 36 | @clp(description = "", hidden = false) class InClass2 extends CommandLineProgram 37 | 38 | @clp(description = "", hidden = true) class OutClass extends NoOpCommandLineProgram 39 | 40 | @clp(description = "", hidden = true) class Out2Class 41 | 42 | class Out3Class 43 | 44 | @clp(description = "", hidden = false) trait OutClass4 45 | 46 | /** For testing simple name collisions */ 47 | object DirOne { 48 | @clp(description = "", hidden = true) class CollisionCommandLineProgram extends CommandLineProgram 49 | } 50 | object DirTwo { 51 | @clp(description = "", hidden = true) class CollisionCommandLineProgram extends CommandLineProgram 52 | } 53 | -------------------------------------------------------------------------------- /src/main/java/com/fulcrumgenomics/sopt/cmdline/ClpAnnotation.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | package com.fulcrumgenomics.sopt.cmdline; 25 | 26 | import com.fulcrumgenomics.sopt.cmdline.ClpGroup; 27 | import com.fulcrumgenomics.sopt.cmdline.Clps; 28 | 29 | import java.lang.annotation.*; 30 | 31 | 32 | /** 33 | * Annotates a command line program with various properties, such as usage (short and long), 34 | * as well as to which CLP group it belongs. 35 | */ 36 | @Retention(RetentionPolicy.RUNTIME) 37 | @Target(ElementType.TYPE) 38 | @Inherited 39 | public @interface ClpAnnotation { 40 | /** 41 | * Should return a detailed description of the CLP that can be down when requesting help on the 42 | * CLP. The first sentence (up to the first period) of the description should summarize the 43 | * CLP's purpose in a way that it can be displayed in a list of CLPs. 44 | */ 45 | String description(); 46 | 47 | /** What group does the CLP belong to, for grouping CLPs at the command line. */ 48 | Class group() default Clps.class; 49 | 50 | /** Should this CLP be hidden from the list shown on the command line. */ 51 | boolean hidden() default false; 52 | } 53 | -------------------------------------------------------------------------------- /src/main/scala/com/fulcrumgenomics/sopt/package.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics 26 | 27 | import com.fulcrumgenomics.sopt.cmdline.{ArgAnnotation, ClpAnnotation} 28 | import com.fulcrumgenomics.sopt.cmdline.ClpAnnotation 29 | 30 | package object sopt { 31 | 32 | /** The type of an option's name when option parsing. */ 33 | type OptionName = String 34 | 35 | /** The type of an option's value when option parsing. */ 36 | type OptionValue = String 37 | 38 | /** 39 | * Used to annotate which fields of a class that has options given at the command line. 40 | * If a command line call looks like "cmd option=foo x=y bar baz" the annotated class 41 | * would have annotations on fields to handle the values of option and x. All options 42 | * must be in the form name=value on the command line. The java type of the option 43 | * will be inferred from the type of the field or from the generic type of the collection 44 | * if this option is allowed more than once. The type must be an enum or 45 | * have a constructor with a single String parameter. 46 | */ 47 | type arg = ArgAnnotation 48 | 49 | /** 50 | * Annotation to be placed on classes that are to be exposed as command line programs. 51 | */ 52 | type clp = ClpAnnotation 53 | } 54 | -------------------------------------------------------------------------------- /src/main/scala/com/fulcrumgenomics/sopt/parsing/OptionParsingExceptions.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics.sopt.parsing 26 | 27 | /** Exception classes for various option parsing errors */ 28 | sealed abstract class OptionParsingException(message: String) extends RuntimeException(message) 29 | 30 | /** For errors in specifying the option name, for example missing leading dashes, nothing after the leading dashes, ... */ 31 | case class OptionNameException(message: String) extends OptionParsingException(message) 32 | 33 | /** For errors when specifying too few values for an option */ 34 | case class TooFewValuesException(message: String) extends OptionParsingException(message) 35 | 36 | /** For errors when specifying too many values for an option */ 37 | case class TooManyValuesException(message: String) extends OptionParsingException(message) 38 | 39 | /** For errors when specifying an option too many times */ 40 | case class OptionSpecifiedMultipleTimesException(message: String) extends OptionParsingException(message) 41 | 42 | /** For errors when parsing the value for a flag field */ 43 | case class IllegalFlagValueException(message: String) extends OptionParsingException(message) 44 | 45 | /** For errors when the same option name is given for different options */ 46 | case class DuplicateOptionNameException(message: String) extends OptionParsingException(message) 47 | 48 | /** For errors when the same option name is not found */ 49 | case class IllegalOptionNameException(message: String) extends OptionParsingException(message) 50 | -------------------------------------------------------------------------------- /src/main/scala/com/fulcrumgenomics/sopt/util/TermCode.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics.sopt.util 26 | 27 | object TermCode { 28 | /** True if we are to use ANSI colors to print to the terminal, false otherwise. */ 29 | var printColor: Boolean = true 30 | } 31 | 32 | /** Base class for all terminal codes (ex ANSI colors for terminal output). The apply method applies the code to the 33 | * string and then terminates the code. */ 34 | abstract class TermCode { 35 | val code: String 36 | def apply(s: String): String = if (TermCode.printColor) s"$code$s${KNRM.code}" else s 37 | } 38 | 39 | case object KNRM extends TermCode { 40 | override val code: String = "\u001B[0m" 41 | } 42 | case object KBLD extends TermCode { 43 | override val code: String = "\u001B[1m" 44 | } 45 | case object KRED extends TermCode { 46 | override val code: String = "\u001B[31m" 47 | } 48 | case object KGRN extends TermCode { 49 | override val code: String = "\u001B[32m" 50 | } 51 | case object KYEL extends TermCode { 52 | override val code: String = "\u001B[33m" 53 | } 54 | case object KBLU extends TermCode { 55 | override val code: String = "\u001B[34m" 56 | } 57 | case object KMAG extends TermCode { 58 | override val code: String = "\u001B[35m" 59 | } 60 | case object KCYN extends TermCode { 61 | override val code: String = "\u001B[36m" 62 | } 63 | case object KWHT extends TermCode { 64 | override val code: String = "\u001B[37m" 65 | } 66 | case object KBLDRED extends TermCode { 67 | override val code: String = "\u001B[1m\u001B[31m" 68 | } 69 | case object KERROR extends TermCode { 70 | override val code: String = "\u001B[1m\u001B[31m" 71 | } 72 | 73 | -------------------------------------------------------------------------------- /src/test/scala/com/fulcrumgenomics/sopt/cmdline/testing/clps/CommandLinePrograms.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | package com.fulcrumgenomics.sopt.cmdline.testing.clps 25 | 26 | import com.fulcrumgenomics.sopt._ 27 | import com.fulcrumgenomics.sopt.cmdline.TestGroup 28 | import com.fulcrumgenomics.sopt.cmdline.TestingClp 29 | 30 | private[cmdline] abstract class CommandLineProgram extends TestingClp 31 | 32 | @clp(description = "", group = classOf[TestGroup], hidden = true) 33 | private[cmdline] class BaseCommandLineProgramTesting extends CommandLineProgram // FIXME: why can't this be called CommandLineProgramTesting??? 34 | 35 | @clp(description = "", group = classOf[TestGroup], hidden = true) 36 | private[cmdline] case class CommandLineProgramOne 37 | () extends BaseCommandLineProgramTesting 38 | 39 | @clp(description = "", group = classOf[TestGroup], hidden = true) 40 | private[cmdline] case class CommandLineProgramTwo 41 | () extends BaseCommandLineProgramTesting 42 | 43 | @clp(description = "", group = classOf[TestGroup], hidden = true) 44 | private[cmdline] case class CommandLineProgramThree 45 | (@arg var argument: String) extends BaseCommandLineProgramTesting // argument should be required 46 | 47 | @clp(description = "", group = classOf[TestGroup], hidden = true) 48 | private[cmdline] case class CommandLineProgramFour 49 | (@arg var argument: String = "default", @arg var flag: Boolean = false) extends BaseCommandLineProgramTesting 50 | 51 | @clp(description = "This is a description", group = classOf[TestGroup], hidden = true) 52 | private[cmdline] case class CommandLineProgramReallyLongArg 53 | ( 54 | @arg var argumentttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt: String 55 | ) extends BaseCommandLineProgramTesting 56 | 57 | @clp(description = "", group = classOf[TestGroup], hidden = true) 58 | private[cmdline] case class CommandLineProgramShortArg 59 | (@arg var argument: String) extends BaseCommandLineProgramTesting 60 | 61 | @clp(description = "", group = classOf[TestGroup], hidden = true) 62 | private[cmdline] case class CommandLineProgramWithMutex 63 | (@arg(mutex = Array("another")) var argument: String, @arg(mutex = Array("argument")) var another: String) extends BaseCommandLineProgramTesting // argument should be required 64 | 65 | @clp(description = "", group = classOf[TestGroup], hidden = true) 66 | private[cmdline] class CommandLineProgramNoArgs extends BaseCommandLineProgramTesting 67 | 68 | @clp(description = "", group = classOf[TestGroup], hidden = true) 69 | private[cmdline] case class CommandLineProgramWithOptionSomeDefault 70 | (@arg var argument: Option[String] = Some("default")) extends BaseCommandLineProgramTesting 71 | 72 | @clp(description = "", group = classOf[TestGroup], hidden = true) 73 | private[cmdline] case class CommandLineProgramWithSeqDefault 74 | (@arg var argument: Seq[String] = Seq("default")) extends BaseCommandLineProgramTesting 75 | -------------------------------------------------------------------------------- /src/main/scala/com/fulcrumgenomics/sopt/parsing/OptionParser.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics.sopt.parsing 26 | 27 | import com.fulcrumgenomics.sopt._ 28 | 29 | import scala.collection.Iterable 30 | import scala.util.{Failure, Success, Try} 31 | 32 | /** Very simple command line parser. 33 | * 34 | * 1. Option specifications should be specified using methods in [[OptionLookup]]: [[OptionLookup.acceptFlag()]], 35 | * [[OptionLookup.acceptSingleValue()]], [[OptionLookup.acceptMultipleValues()]]. 36 | * 2. Call [[parse()]] to parse the argument strings. 37 | * 3. Either (1) Query for option values in [[OptionLookup]]: [[OptionLookup.hasOptionValues]] and 38 | * [[OptionLookup.optionValues()]], or (2) traverse tuples of name and values using [[OptionParser().foreach()]] or 39 | * similar methods. 40 | * 41 | * See the README.md for more information on valid arguments to [[OptionParser]]. */ 42 | class OptionParser(val argFilePrefix: Option[String] = None) extends OptionLookup with Iterable[(OptionName, List[OptionValue])] { 43 | private var remainingArgs: Iterable[String] = Nil 44 | 45 | /** returns any remaining args that were not parsed in the previous call to `parse`. */ 46 | def remaining: Iterable[String] = remainingArgs 47 | 48 | /** Parse the given args. If an error was found, the first error is returned */ 49 | def parse(args: String*): Try[this.type] = parse(args.toList) 50 | 51 | /** Parse a single arg. If an error was found, the error is returned. If no more args are found, returns a success. 52 | * Otherwise recursively calls itself. */ 53 | private def parseRecursively(argTokenizer: ArgTokenizer, argTokenCollator: ArgTokenCollator): Try[this.type] = { 54 | if (!argTokenCollator.hasNext) { 55 | remainingArgs = argTokenCollator.takeRemaining 56 | Success(this) 57 | } 58 | else { 59 | argTokenCollator.next() match { 60 | case Failure(failure) => 61 | remainingArgs = argTokenCollator.takeRemaining 62 | Failure(failure) 63 | case Success(ArgOptionAndValues(name, values)) => 64 | addOptionValues(name, values:_*) match { 65 | case Failure(failure) => 66 | remainingArgs = Seq(ArgTokenizer.addBackDashes(name)) ++ values ++ argTokenCollator.takeRemaining 67 | Failure(failure) 68 | case _ => parseRecursively(argTokenizer=argTokenizer, argTokenCollator=argTokenCollator) 69 | } 70 | } 71 | } 72 | } 73 | 74 | /** Parse the given args. If an error was found, the first error is returned */ 75 | def parse(args: List[String]) : Try[this.type] = { 76 | val argTokenizer = new ArgTokenizer(args, argFilePrefix=argFilePrefix) 77 | val argTokenCollator = new ArgTokenCollator(argTokenizer) 78 | parseRecursively(argTokenizer=argTokenizer, argTokenCollator=argTokenCollator) 79 | } 80 | 81 | /** Generates an iterator over options with values. If an error occurred in parsing, there will be no options */ 82 | override def iterator: Iterator[(OptionName, List[OptionValue])] = { 83 | this.optionMap.values.toSeq.distinct.filter(_.nonEmpty).iterator.map(o => (o.optionNames.head, o.toList)) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/main/scala/com/fulcrumgenomics/sopt/cmdline/CommandLineException.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics.sopt.cmdline 26 | 27 | object CommandLineException { 28 | /** Prepends the exception name to the message. */ 29 | private def formatMessage(msg: String, e: Option[Exception]): String = { 30 | e.map(x => s"Exception: ${x.getClass.getSimpleName}\n").getOrElse("") + "Error: " + msg 31 | } 32 | /** Creates a new CommandLineException with the message formatted for the command line. */ 33 | def apply(e: CommandLineException): CommandLineException = new CommandLineException(formatMessage(e.getMessage, Some(e)), None) 34 | /** Creates a new CommandLineException with the message formatted for the command line. */ 35 | def apply(e: ValidationException): CommandLineException = new CommandLineException(formatMessage(e.messages.mkString("\n"), Some(e)), None) 36 | /** Creates a new CommandLineException with the message formatted for the command line. */ 37 | def apply(e: Exception): CommandLineException = new CommandLineException(formatMessage(e.getMessage, Some(e)), None) 38 | } 39 | 40 | /** Base class for all exceptions thrown by the command line parsing */ 41 | class CommandLineException(msg: String, e: Option[Exception] = None) extends RuntimeException(msg, e.orNull) { 42 | def this(msg: String, e: Exception) = this(msg, Some(e)) 43 | } 44 | 45 | /** Exception thrown when the user forgets to specify an argument */ 46 | case class MissingArgumentException(msg: String, e: Option[Exception] = None) extends CommandLineException(msg = msg, e = e) { 47 | def this(msg: String, e: Exception) = this(msg, Some(e)) 48 | } 49 | 50 | /** Exception thrown when the user forgets to specify an annotation */ 51 | case class MissingAnnotationException(msg: String, e: Option[Exception] = None) extends CommandLineException(msg = msg, e = e) { 52 | def this(msg: String, e: Exception) = this(msg, Some(e)) 53 | } 54 | 55 | /** Exception thrown when trying to convert to specific value */ 56 | case class ValueConversionException(msg: String, e: Option[Exception] = None) extends CommandLineException(msg = msg, e = e) { 57 | def this(msg: String, e: Exception) = this(msg, Some(e)) 58 | } 59 | 60 | /** Exception thrown when something internally goes wrong with command line parsing */ 61 | case class CommandLineParserInternalException(msg: String, e: Option[Exception] = None) extends CommandLineException(msg = msg, e = e) { 62 | def this(msg: String, e: Exception) = this(msg, Some(e)) 63 | } 64 | 65 | /** Exception thrown when something there is user error (never happens) */ 66 | case class UserException(msg: String, e: Option[Exception] = None) extends CommandLineException(msg = msg, e = e) { 67 | def this(msg: String, e: Exception) = this(msg, Some(e)) 68 | } 69 | 70 | /** Exception thrown when something the user gives a bad value */ 71 | case class BadArgumentValue(msg: String, e: Option[Exception] = None) extends CommandLineException(msg = msg, e = e) { 72 | def this(msg: String, e: Exception) = this(msg, Some(e)) 73 | } 74 | 75 | /** Exception thrown when something the annotation on a field is incorrect. */ 76 | case class BadAnnotationException(msg: String, e: Option[Exception] = None) extends CommandLineException(msg = msg, e = e) { 77 | def this(msg: String, e: Exception) = this(msg, Some(e)) 78 | } 79 | -------------------------------------------------------------------------------- /.github/workflows/unittests.yaml: -------------------------------------------------------------------------------- 1 | name: unit tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_call: 7 | secrets: 8 | CODECOV_TOKEN: 9 | required: true 10 | SONATYPE_USER: 11 | required: false 12 | SONATYPE_PASS: 13 | required: false 14 | 15 | concurrency: 16 | group: unittest-${{ github.workflow }}-${{ github.ref }} 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | test: 21 | runs-on: ubuntu-latest 22 | strategy: 23 | matrix: 24 | jvm: ["temurin:1.8.0-442", "temurin:1.11.0.27", "temurin:1.17.0.15", "temurin:1.21.0.7", "temurin:1.22.0.2"] 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 28 | with: 29 | fetch-depth: 0 30 | - name: Setup cache 31 | uses: coursier/cache-action@4e2615869d13561d626ed48655e1a39e5b192b3c # v6.4.7 32 | - name: setup Scala 33 | uses: coursier/setup-action@039f736548afa5411c1382f40a5bd9c2d30e0383 # v1.3.9 34 | with: 35 | apps: scala:2.13.14 scalac:2.13.14 scaladoc:2.13.14 sbt:1.11.2 36 | jvm: ${{ matrix.jvm }} 37 | - name: Unit Tests 38 | run: | 39 | set -e 40 | sbt clean coverage test 41 | sbt coverageReport coverageAggregate 42 | - name: Code Coverage 43 | uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5.4.2 44 | with: 45 | token: ${{ secrets.CODECOV_TOKEN }} 46 | flags: unittests # optional 47 | fail_ci_if_error: true # optional (default = false) 48 | verbose: true # optional (default = false) 49 | # while the "docs" job below tests building the online docs, this is added here 50 | # to fail-fast when docs are not correctly written. 51 | - name: Ensure docs will compile (fast-fail) 52 | run: | 53 | set -e 54 | sbt doc 55 | release: 56 | if: ${{ github.repository == 'fulcrumgenomics/sopt' && github.ref == 'refs/heads/main' && github.event_name != 'workflow_call' }} 57 | needs: test 58 | runs-on: ubuntu-latest 59 | environment: github-actions 60 | env: 61 | SONATYPE_USER: ${{ secrets.SONATYPE_USER }} 62 | SONATYPE_PASS: ${{ secrets.SONATYPE_PASS }} 63 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 64 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 65 | steps: 66 | - name: Check for secrets.SONATYPE_USER 67 | if: ${{ env.SONATYPE_USER == '' }} 68 | run: | 69 | echo '"SONATYPE_USER" secret not set' 70 | echo 'please go to "settings > secrets > actions" to create it' 71 | - name: Check for secrets.SONATYPE_PASS 72 | if: ${{ env.SONATYPE_PASS == '' }} 73 | run: | 74 | echo '"SONATYPE_PASS" secret not set' 75 | echo 'please go to "settings > secrets > actions" to create it' 76 | - name: Check for secrets.PGP_PASSPHRASE 77 | if: ${{ env.PGP_PASSPHRASE== '' }} 78 | run: | 79 | echo '"PGP_PASSPHRASE" secret not set' 80 | echo 'please go to "settings > secrets > actions" to create it' 81 | - name: Check for secrets.PGP_SECRET 82 | if: ${{ env.PGP_SECRET== '' }} 83 | run: | 84 | echo '"PGP_SECRET" secret not set' 85 | echo 'please go to "settings > secrets > actions" to create it' 86 | - name: Export tty 87 | run: | 88 | echo "GPG_TTY=$(tty)" >> $GITHUB_ENV 89 | - name: Checkout 90 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 91 | with: 92 | fetch-depth: 0 93 | # To work with the `pull_request` or any other non-`push` even with git-auto-commit 94 | ref: ${{ github.head_ref }} 95 | - name: Setup cache 96 | uses: coursier/cache-action@4e2615869d13561d626ed48655e1a39e5b192b3c # v6.4.7 97 | - name: setup Scala 98 | uses: coursier/setup-action@039f736548afa5411c1382f40a5bd9c2d30e0383 # v1.3.9 99 | with: 100 | apps: scala:2.13.14 scalac:2.13.14 scaladoc:2.13.14 sbt:1.11.2 101 | jvm: "temurin:1.8.0-442" 102 | - name: Setup GPG 103 | shell: bash -l {0} 104 | run: | 105 | echo "$PGP_SECRET" | base64 --decode | gpg --import --batch --yes 106 | - name: Build and sign artifacts 107 | shell: bash -l {0} 108 | run: | 109 | sbt +publishSigned 110 | -------------------------------------------------------------------------------- /src/main/java/com/fulcrumgenomics/sopt/cmdline/ArgAnnotation.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | package com.fulcrumgenomics.sopt.cmdline; 25 | 26 | import java.lang.annotation.*; 27 | 28 | /** 29 | * Used to annotate which fields of a CommandLineTask are options given at the command line. 30 | * If a command line call looks like "cmd option=foo x=y bar baz" the CommandLineTask 31 | * would have annotations on fields to handle the values of option and x. All options 32 | * must be in the form name=value on the command line. The java type of the option 33 | * will be inferred from the type of the field or from the generic type of the collection 34 | * if this option is allowed more than once. The type must be an enum or 35 | * have a constructor with a single String parameter. 36 | */ 37 | @Retention(RetentionPolicy.RUNTIME) 38 | @Target({ElementType.PARAMETER}) 39 | @Inherited 40 | @Documented 41 | public @interface ArgAnnotation { 42 | 43 | /** 44 | * The full name of the command-line argument. Full names should be 45 | * prefixed on the command-line with a double dash (--). If not specified 46 | * then default is used, which is to translate the field name into a GNU style 47 | * option name by breaking words on camel case, joining words back together with 48 | * hyphens, and converting to lower case (e.g. myThing => my-thing). 49 | * @return Selected full name, or "" to use the default. 50 | */ 51 | String name() default ""; 52 | 53 | /** 54 | * Specified short name of the command. Short names should be prefixed 55 | * with a single dash. ArgAnnotation values can directly abut single-char 56 | * flags or be separated from them by a space. 57 | * @return Selected short name, or 0 for none. 58 | */ 59 | char flag() default 0; 60 | 61 | /** 62 | * Documentation for the command-line argument. Should appear when the 63 | * --help argument is specified. 64 | * @return Doc string associated with this command-line argument. 65 | */ 66 | String doc() default "Undocumented option."; 67 | 68 | /** 69 | * Array of option names that cannot be used in conjunction with this one. 70 | * If 2 options are mutually exclusive and are both required (i.e. are not Option types, 71 | * don't have a default value or are a Collection with minElements > 0) it will be 72 | * interpreted as one OR the other is required and an exception will only be thrown if 73 | * neither are specified. 74 | */ 75 | String[] mutex() default {}; 76 | 77 | /** 78 | * Does this option have special treatment in the argument parsing system. 79 | * Some examples are arguments_file and help, which have special behavior in the parser. 80 | * This is intended for documenting these options. 81 | */ 82 | boolean special() default false; 83 | 84 | /** 85 | * Are the contents of this argument private and should be kept out of logs. 86 | * Examples of sensitive arguments are encryption and api keys. 87 | */ 88 | boolean sensitive() default false; 89 | 90 | /** 91 | * If the argument is a collection, then the minimum number of arguments required. This is ignored 92 | * for non-collection arguments. 93 | * */ 94 | int minElements() default 1; 95 | 96 | /** 97 | * If the argument is a collection, then the maximum number of arguments required. This is ignored 98 | * for non-collection arguments. 99 | * */ 100 | int maxElements() default Integer.MAX_VALUE; 101 | 102 | /** 103 | * The named group to which the command-line argument. The group name can be any string, and the associated 104 | * command-line arguments will be displayed in the help in a separate section from those without a group name (the 105 | * empty string). 106 | */ 107 | String group() default ""; 108 | } 109 | -------------------------------------------------------------------------------- /src/test/scala/com/fulcrumgenomics/sopt/util/ParsingUtilTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) $year Fulcrum Genomics 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | * 24 | */ 25 | package com.fulcrumgenomics.sopt.util 26 | 27 | import java.lang.reflect.Field 28 | 29 | import com.fulcrumgenomics.sopt.cmdline.CommandLineException 30 | import com.fulcrumgenomics.sopt.cmdline.testing.simple.CommandLineProgram 31 | import org.scalatest.{OptionValues, PrivateMethodTester} 32 | 33 | object ParsingUtilTest { 34 | /** Helper to get the first declared field */ 35 | def getField(clazz: Class[_]): Field = clazz.getDeclaredFields.head 36 | 37 | } 38 | 39 | class ParsingUtilTest extends UnitSpec with OptionValues with PrivateMethodTester { 40 | 41 | private val simplePackageList = List("com.fulcrumgenomics.sopt.cmdline.testing.simple") 42 | 43 | import com.fulcrumgenomics.sopt.util.ParsingUtil._ 44 | 45 | it should "find classes that extend CommandLineProgram with the @CLP annotation" in { 46 | val map = findClpClasses[CommandLineProgram](simplePackageList) 47 | 48 | import com.fulcrumgenomics.sopt.cmdline.testing.simple._ 49 | map should contain key classOf[InClass] 50 | map should contain key classOf[InClass2] 51 | map should not contain key (classOf[NoOpCommandLineProgram]) 52 | map should not contain key (classOf[OutClass]) 53 | map should not contain key (classOf[Out2Class]) 54 | map should not contain key (classOf[Out3Class]) 55 | } 56 | 57 | it should "throw a CommandLineException when two Pipelines have the same simple name" in { 58 | an[CommandLineException] should be thrownBy findClpClasses[CommandLineProgram](simplePackageList, includeHidden = true) 59 | } 60 | 61 | // it should "find classes that are missing the annotation @CLP" in { 62 | // an[BadAnnotationException] should be thrownBy getClassToPropertyMap(List("dagr.core.cmdline.parsing.testing.missing")) 63 | // } 64 | 65 | // it should "get the type for a field" in { 66 | // getFieldClass("list", classOf[WithList], unwrapContainers = false) shouldBe classOf[List[_]] 67 | // getFieldClass("list", classOf[WithIntList], unwrapContainers = false) shouldBe classOf[List[_]] 68 | // getFieldClass("list", classOf[WithJavaCollection], unwrapContainers = false) shouldBe classOf[util.Collection[_]] 69 | // getFieldClass("v", classOf[WithOption], unwrapContainers = false) shouldBe classOf[Option[_]] 70 | // getFieldClass("v", classOf[WithIntOption], unwrapContainers = false) shouldBe classOf[Option[_]] 71 | // getFieldClass("v", classOf[WithInt], unwrapContainers = false) shouldBe classOf[Int] 72 | // getFieldClass("path", classOf[WithPathToBamOption], unwrapContainers = false) shouldBe classOf[Option[_]] 73 | // an[CommandLineException] should be thrownBy getFieldClass("doesNotExist", classOf[WithInt], unwrapContainers = false) 74 | // } 75 | // 76 | // it should "get the generic type for a field" in { 77 | // getFieldClass("list", classOf[WithList]) shouldBe classOf[Any] 78 | // getFieldClass("list", classOf[WithIntList]) shouldBe classOf[Int] 79 | // getFieldClass("list", classOf[WithJavaCollection]) shouldBe classOf[Any] 80 | // getFieldClass("v", classOf[WithOption]) shouldBe classOf[Any] 81 | // getFieldClass("v", classOf[WithIntOption]) shouldBe classOf[Int] 82 | //// getFieldClass("v", classOf[WithInt]) shouldBe Symbol("empty") 83 | // getFieldClass("path", classOf[WithPathToBamOption]) shouldBe classOf[PathToBam] 84 | // an[CommandLineException] should be thrownBy getFieldClass("doesNotExist", classOf[WithInt]) 85 | // an[CommandLineException] should be thrownBy getFieldClass("map", classOf[WithMap]) // multiple generic types! 86 | // } 87 | 88 | // it should "gets the underlying type for a field" in { 89 | // import dagr.core.cmdline.parsing.testing.fields._ 90 | // 91 | // getUnitClass("list", classOf[WithList]) shouldBe classOf[Any] 92 | // getUnitClass("list", classOf[WithIntList]) shouldBe classOf[java.lang.Integer] 93 | // getUnitClass("list", classOf[WithJavaCollection]) shouldBe classOf[Any] 94 | // getUnitClass("v", classOf[WithOption]) shouldBe classOf[Any] 95 | // getUnitClass("v", classOf[WithIntOption]) shouldBe classOf[java.lang.Integer] 96 | // getUnitClass("v", classOf[WithInt]) shouldBe classOf[java.lang.Integer] 97 | // getUnitClass("path", classOf[WithPathToBamOption]) shouldBe classOf[Path] 98 | // } 99 | } 100 | -------------------------------------------------------------------------------- /src/main/scala/com/fulcrumgenomics/sopt/parsing/ArgTokenCollator.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics.sopt.parsing 26 | 27 | import scala.collection.mutable 28 | import scala.util.{Failure, Success, Try} 29 | import com.fulcrumgenomics.sopt.parsing.ArgTokenizer.{ArgValue, ArgOptionAndValue, ArgOption, Token} 30 | 31 | case class ArgOptionAndValues(name: String, values: Seq[String]) 32 | 33 | object ArgTokenCollator { 34 | private[sopt] def isArgValueOrSameNameArgOptionAndValue(tryToken: Try[Token], name: String): Boolean = { 35 | tryToken match { 36 | case Success(ArgValue(value)) => true 37 | case Success(ArgOptionAndValue(`name`, value)) => true 38 | case _ => false 39 | } 40 | } 41 | } 42 | 43 | /** Collates Tokens into name and values, such that there is no value without an associated option name. */ 44 | class ArgTokenCollator(argTokenizer: ArgTokenizer) extends Iterator[Try[ArgOptionAndValues]] { 45 | private val iter = argTokenizer 46 | private var nextMaybe: Option[Try[ArgOptionAndValues]] = None 47 | 48 | this.advance() // to initialize nextOption 49 | 50 | /** True if there is another value, false otherwise. */ 51 | def hasNext: Boolean = nextMaybe.isDefined 52 | 53 | /** Gets the next token wrapped in a Try. A failure is returned if the provided [[ArgTokenizer]] returned a failure 54 | * or if we could not find an option name ([[ArgOption]] or [[ArgOptionAndValue]] before finding an 55 | * option value ([[ArgValue]]). Once a failure is encountered, no more tokens are returned. 56 | */ 57 | def next: Try[ArgOptionAndValues] = { 58 | nextMaybe match { 59 | case None => throw new NoSuchElementException("'next' was called when 'hasNext' is false") 60 | case Some(Failure(ex)) => 61 | this.nextMaybe = None 62 | Failure(ex) 63 | case Some(Success(value)) => 64 | this.advance() 65 | Success(value) 66 | } 67 | } 68 | 69 | /** Tries to get the next token that has an option name, and adds any values to `values` if found. */ 70 | private def nextName(values: mutable.ListBuffer[String]): Try[String] = iter.head match { 71 | case Success(ArgOption(name)) => 72 | iter.next 73 | Success(name) 74 | case Success(ArgOptionAndValue(name, value)) => 75 | iter.next 76 | values += value; Success(name) 77 | case Success(ArgValue(value)) => 78 | // do not consume the underlying iterator, so that the value is returned by argTokenizer.takeRemaining, otherwise 79 | // we would have to keep track of it. This assumes we do not move past the first failure. 80 | Failure(OptionNameException(s"Illegal option: '$value'")) 81 | case Failure(ex) => 82 | iter.next 83 | Failure(ex) 84 | } 85 | 86 | /** Find values with the same option name, may only be ArgValue and ArgOptionAndValue. */ 87 | private def addValuesWithSameName(name: String, values: mutable.ListBuffer[String]): Unit = { 88 | // find values with the same option name, may only be ArgValue and ArgOptionAndValue 89 | while (iter.hasNext && ArgTokenCollator.isArgValueOrSameNameArgOptionAndValue(iter.head, name)) { 90 | iter.next match { 91 | case Success(ArgValue(value)) => values += value 92 | case Success(ArgOptionAndValue(`name`, value)) => values += value 93 | case _ => throw new IllegalStateException("Should never reach here") 94 | } 95 | } 96 | } 97 | 98 | /** Advance the underlying iterator and update `nextOption`. */ 99 | private def advance(): Unit = { 100 | if (!iter.hasNext) { 101 | this.nextMaybe = None 102 | } 103 | else { 104 | val values: mutable.ListBuffer[String] = new mutable.ListBuffer[String]() 105 | 106 | // First try to get an a token that has an option name, next add any subsequent tokens that have just values, or 107 | // the same option name with values. 108 | nextName(values) match { 109 | case Failure(ex) => this.nextMaybe = Some(Failure(ex)) 110 | case Success(name) => 111 | addValuesWithSameName(name, values) 112 | // gather them all back up 113 | this.nextMaybe = Some(Success(ArgOptionAndValues(name = name, values = values.toSeq))) 114 | } 115 | } 116 | } 117 | 118 | def takeRemaining: Seq[String] = { 119 | val remaining: Seq[String] = this.nextMaybe match { 120 | case Some(Success(value: ArgOptionAndValues)) => Seq(ArgTokenizer.addBackDashes(value.name)) ++ value.values 121 | case _ => Nil 122 | } 123 | remaining ++ argTokenizer.takeRemaining 124 | } 125 | } 126 | 127 | -------------------------------------------------------------------------------- /src/test/scala/com/fulcrumgenomics/sopt/SoptTest.scala: -------------------------------------------------------------------------------- 1 | package com.fulcrumgenomics.sopt 2 | 3 | import java.nio.file.Path 4 | 5 | import com.fulcrumgenomics.sopt.Sopt.Group 6 | import com.fulcrumgenomics.sopt.SoptTest.{FancyPath, PositiveInt} 7 | import com.fulcrumgenomics.sopt.cmdline.Clps 8 | import com.fulcrumgenomics.sopt.util.UnitSpec 9 | 10 | /** Some types used to test returning of typedef vs. underlying types. */ 11 | object SoptTest { 12 | type FancyPath = Path 13 | type PositiveInt = Int 14 | } 15 | 16 | /** A trait so we can search for your the subclasses for this test. */ 17 | trait SoptTestCommand 18 | 19 | /** A small CLP */ 20 | @clp(group=classOf[Clps], description="A _first_ test program for Sopt.") 21 | class SoptCommand1 22 | ( @arg(flag='i', doc="**Input** file.") val input: FancyPath, 23 | @arg(flag='o', doc="Output file.") val output: Option[FancyPath], 24 | @arg(flag='n', doc="Numbers.", minElements=0, maxElements=100) val numbers: Seq[PositiveInt] = Seq(1, 2, 3) 25 | ) extends SoptTestCommand 26 | 27 | /** A small CLP */ 28 | @clp(group=classOf[Clps], description="A second test program for Sopt.") 29 | class SoptCommand2 30 | ( @arg(flag='i', doc="Input file.") val inputPath: FancyPath, 31 | @arg(flag='o', doc="Output file.") val outputPath: FancyPath, 32 | nonArgArgument: String = "Shhh, I'm not really here", 33 | val nonArgArgument2: String = "Me either!", 34 | private val nonArgArgument3: String = "Nobody here but us @args" 35 | ) extends SoptTestCommand 36 | 37 | 38 | /** The actual tests. */ 39 | class SoptTest extends UnitSpec { 40 | "Sopt.find" should "find the two concrete subclasses of SoptTestCommand" in { 41 | val commands = Sopt.find[SoptTestCommand](Seq("com.fulcrumgenomics.sopt")) 42 | commands should have size 2 43 | commands should contain theSameElementsAs Seq(classOf[SoptCommand1], classOf[SoptCommand2]) 44 | } 45 | 46 | "Sopt.inspect" should "return useful and correct metadata for SoptCommand1" in { 47 | val clp = Sopt.inspect(classOf[SoptCommand1]) 48 | clp.name shouldBe "SoptCommand1" 49 | clp.group shouldBe Group(new Clps().name, new Clps().description, new Clps().rank) 50 | clp.description shouldBe "A _first_ test program for Sopt." 51 | clp.descriptionAsText shouldBe "A first test program for Sopt." 52 | clp.descriptionAsHtml shouldBe "

A first test program for Sopt.

" 53 | clp.hidden shouldBe false 54 | 55 | val args = clp.args.map(a => a.name -> a).toMap 56 | 57 | args("input").name shouldBe "input" 58 | args("input").flag shouldBe Some('i') 59 | args("input").kind shouldBe "FancyPath" 60 | args("input").defaultValues shouldBe Seq.empty 61 | args("input").description shouldBe "**Input** file." 62 | args("input").descriptionAsText shouldBe "Input file." 63 | args("input").descriptionAsHtml shouldBe "

Input file.

" 64 | args("input").sensitive shouldBe false 65 | args("input").minValues shouldBe 1 66 | args("input").maxValues shouldBe 1 67 | 68 | args("output").name shouldBe "output" 69 | args("output").flag shouldBe Some('o') 70 | args("output").kind shouldBe "FancyPath" 71 | args("output").defaultValues shouldBe Seq.empty 72 | args("output").description shouldBe "Output file." 73 | args("output").descriptionAsText shouldBe "Output file." 74 | args("output").descriptionAsHtml shouldBe "

Output file.

" 75 | args("output").sensitive shouldBe false 76 | args("output").minValues shouldBe 0 77 | args("output").maxValues shouldBe 1 78 | 79 | args("numbers").name shouldBe "numbers" 80 | args("numbers").flag shouldBe Some('n') 81 | args("numbers").kind shouldBe "PositiveInt" 82 | args("numbers").defaultValues shouldBe Seq("1", "2", "3") 83 | args("numbers").description shouldBe "Numbers." 84 | args("numbers").descriptionAsText shouldBe "Numbers." 85 | args("numbers").descriptionAsHtml shouldBe "

Numbers.

" 86 | args("numbers").sensitive shouldBe false 87 | args("numbers").minValues shouldBe 0 88 | args("numbers").maxValues shouldBe 100 89 | } 90 | 91 | "Sopt.inspect" should "return useful and correct metadata for SoptCommand2" in { 92 | val clp = Sopt.inspect(classOf[SoptCommand2]) 93 | clp.name shouldBe "SoptCommand2" 94 | clp.group shouldBe Group(new Clps().name, new Clps().description, new Clps().rank) 95 | clp.description shouldBe "A second test program for Sopt." 96 | clp.descriptionAsText shouldBe "A second test program for Sopt." 97 | clp.descriptionAsHtml shouldBe "

A second test program for Sopt.

" 98 | clp.hidden shouldBe false 99 | 100 | val args = clp.args.map(a => a.name -> a).toMap 101 | args.size shouldBe 2 102 | 103 | args("input-path").name shouldBe "input-path" 104 | args("input-path").flag shouldBe Some('i') 105 | args("input-path").kind shouldBe "FancyPath" 106 | args("input-path").defaultValues shouldBe Seq.empty 107 | args("input-path").description shouldBe "Input file." 108 | args("input-path").descriptionAsText shouldBe "Input file." 109 | args("input-path").descriptionAsHtml shouldBe "

Input file.

" 110 | args("input-path").sensitive shouldBe false 111 | args("input-path").minValues shouldBe 1 112 | args("input-path").maxValues shouldBe 1 113 | 114 | args("output-path").name shouldBe "output-path" 115 | args("output-path").flag shouldBe Some('o') 116 | args("output-path").kind shouldBe "FancyPath" 117 | args("output-path").defaultValues shouldBe Seq.empty 118 | args("output-path").description shouldBe "Output file." 119 | args("output-path").descriptionAsText shouldBe "Output file." 120 | args("output-path").descriptionAsHtml shouldBe "

Output file.

" 121 | args("output-path").sensitive shouldBe false 122 | args("output-path").minValues shouldBe 1 123 | args("output-path").maxValues shouldBe 1 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/test/scala/com/fulcrumgenomics/sopt/parsing/ArgTokenCollatorTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics.sopt.parsing 26 | 27 | import com.fulcrumgenomics.sopt.parsing.ArgTokenizer.{ArgOption, ArgOptionAndValue, ArgValue} 28 | import com.fulcrumgenomics.sopt.util.UnitSpec 29 | 30 | import scala.util.{Failure, Success} 31 | 32 | 33 | class ArgTokenCollatorTest extends UnitSpec { 34 | 35 | "ArgTokenCollator.isArgValueOrSameNameArgOptionAndValue" should "match an arg value" in { 36 | ArgTokenCollator.isArgValueOrSameNameArgOptionAndValue(tryToken = Success(ArgValue("value")), "name") shouldBe true 37 | ArgTokenCollator.isArgValueOrSameNameArgOptionAndValue(tryToken = Success(ArgValue("value")), "") shouldBe true 38 | } 39 | 40 | it should "match an arg option and value" in { 41 | ArgTokenCollator.isArgValueOrSameNameArgOptionAndValue(tryToken = Success(ArgOptionAndValue("name", "value")), "name") shouldBe true 42 | ArgTokenCollator.isArgValueOrSameNameArgOptionAndValue(tryToken = Success(ArgOptionAndValue("foo", "value")), "foo") shouldBe true 43 | } 44 | 45 | it should "not match an arg option and value when the names are different" in { 46 | ArgTokenCollator.isArgValueOrSameNameArgOptionAndValue(tryToken = Success(ArgOptionAndValue("name1", "value")), "name") 47 | ArgTokenCollator.isArgValueOrSameNameArgOptionAndValue(tryToken = Success(ArgOptionAndValue("foo1", "value")), "foo") 48 | } 49 | 50 | it should "not match if a failure is given" in { 51 | ArgTokenCollator.isArgValueOrSameNameArgOptionAndValue(tryToken = Failure(new Exception), "name") 52 | ArgTokenCollator.isArgValueOrSameNameArgOptionAndValue(tryToken = Failure(new Exception), "foo") 53 | } 54 | 55 | it should "not match if an ArgOption is given" in { 56 | ArgTokenCollator.isArgValueOrSameNameArgOptionAndValue(tryToken = Success(ArgOption("name")), "name") 57 | } 58 | 59 | "ArgTokenCollator.hasNext" should "should return true when starting with an ArgValue but the returned value should be a failure" in { 60 | val tokenizer = new ArgTokenizer("value") 61 | val collator = new ArgTokenCollator(tokenizer) 62 | collator.hasNext shouldBe true 63 | collator.isEmpty shouldBe false 64 | val n = collator.next 65 | n.isFailure shouldBe true 66 | n.failed.get.getClass shouldBe classOf[OptionNameException] 67 | } 68 | 69 | "ArgTokenCollator.advance" should "should group values for the same long option" in { 70 | val tokenizer = new ArgTokenizer("--value", "value", "--value", "value", "value") 71 | val collator = new ArgTokenCollator(tokenizer) 72 | val tokens = collator.toSeq 73 | tokens.size shouldBe 2 74 | // token 1 75 | var tokenTry = tokens.head 76 | tokenTry.isSuccess shouldBe true 77 | var token = tokenTry.get 78 | token.name shouldBe "value" 79 | token.values shouldBe Seq("value") 80 | // token 2 81 | tokenTry = tokens.last 82 | tokenTry.isSuccess shouldBe true 83 | token = tokenTry.get 84 | token.name shouldBe "value" 85 | token.values shouldBe Seq("value", "value") 86 | } 87 | 88 | it should "should group values for the same short (flag) option" in { 89 | val tokenizer = new ArgTokenizer("-vvalue", "-vvalue", "value") 90 | val collator = new ArgTokenCollator(tokenizer) 91 | val tokens = collator.toSeq 92 | tokens.size shouldBe 1 93 | // token 1 94 | var tokenTry = tokens.head 95 | tokenTry.isSuccess shouldBe true 96 | val token = tokenTry.get 97 | token.name shouldBe "v" 98 | token.values shouldBe Seq("value", "value", "value") 99 | } 100 | 101 | "ArgTokenCollator.isArgValueOrSameNameArgOptionAndValue" should "return true for args with the same name, false otherwise" in { 102 | ArgTokenCollator.isArgValueOrSameNameArgOptionAndValue(Success(ArgValue("value")), "opt") shouldBe true 103 | ArgTokenCollator.isArgValueOrSameNameArgOptionAndValue(Success(ArgOptionAndValue("opt", "value")), "opt") shouldBe true 104 | ArgTokenCollator.isArgValueOrSameNameArgOptionAndValue(Success(ArgOptionAndValue("opt2", "value")), "opt") shouldBe false 105 | ArgTokenCollator.isArgValueOrSameNameArgOptionAndValue(Success(ArgOption("opt")), "opt") shouldBe false 106 | } 107 | 108 | "ArgTokenCollator.takeRemaining" should "return remaining values include those in nextOption when a failure is encountered" in { 109 | val args = Seq("-n", "value", "", "-s") 110 | 111 | { 112 | val collator = new ArgTokenCollator(new ArgTokenizer(args)) 113 | collator.takeRemaining shouldBe args 114 | } 115 | { 116 | val collator = new ArgTokenCollator(new ArgTokenizer(args)) 117 | collator.hasNext shouldBe true 118 | val nextVal = collator.next 119 | nextVal shouldBe Symbol("success") 120 | nextVal.get shouldBe ArgOptionAndValues(name="n", values=Seq("value")) 121 | collator.takeRemaining shouldBe Seq("", "-s") 122 | } 123 | { 124 | val collator = new ArgTokenCollator(new ArgTokenizer(args)) 125 | collator.hasNext shouldBe true 126 | var nextVal = collator.next 127 | nextVal shouldBe Symbol("success") 128 | nextVal.get shouldBe ArgOptionAndValues(name="n", values=Seq("value")) 129 | nextVal = collator.next 130 | nextVal shouldBe Symbol("failure") 131 | collator.takeRemaining shouldBe Seq("", "-s") 132 | } 133 | { 134 | val collator = new ArgTokenCollator(new ArgTokenizer(Seq("val0"))) 135 | collator.hasNext shouldBe true 136 | collator.takeRemaining shouldBe Seq("val0") 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/test/scala/com/fulcrumgenomics/sopt/cmdline/ClpReflectiveBuilderTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics.sopt.cmdline 26 | 27 | import com.fulcrumgenomics.commons.reflect.ReflectionUtil 28 | import com.fulcrumgenomics.commons.util.LogLevel 29 | import com.fulcrumgenomics.sopt.arg 30 | import com.fulcrumgenomics.sopt.util.UnitSpec 31 | 32 | private case class IntNoDefault(@arg v: Int) 33 | private case class TwoParamNoDefault(@arg v: Int = 2, @arg w: Int) 34 | private case class IntDefault(@arg v: Int = 2) 35 | private case class DefaultWithOption(@arg w: Option[Int] = None) 36 | private case class DefaultWithSomeOption(@arg w: Option[Int] = Some(4)) 37 | private case class NoParams() 38 | private case class StringDefault(@arg s: String = "null") 39 | private case class ComplexDefault(@arg v: StringDefault = StringDefault()) 40 | private case class ComplexNoDefault(@arg v: StringDefault) 41 | private case class NoValuesInCollection(@arg(minElements=0) v: Seq[Int] = Seq.empty) 42 | 43 | private class NotCaseClass(@arg val i:Int, @arg val l:Long=123, @arg val o : Option[String] = None) 44 | private class ParamsNotVals(@arg i:Int, @arg l:Long) { 45 | val x:Int = i 46 | val y:Long = l 47 | } 48 | 49 | private class SecondaryConstructor1(val x:Int, val y:Int) { def this(@arg a:Int) = this(a, a*2) } 50 | private class NoAnnotation(val x:Int) 51 | private class ConflictingNames(@arg(name="name") val x:Int, @arg(name="name") val y:Int) 52 | 53 | private class LogLevelEnum(@arg logLevel: LogLevel) 54 | 55 | class ClpReflectiveBuilderTest extends UnitSpec { 56 | 57 | "ClpReflectiveBuilder" should "instantiate a case-class with defaults" in { 58 | val t = new ClpReflectiveBuilder(classOf[IntDefault]).buildDefault() 59 | t.v should be(2) 60 | val tt = new ClpReflectiveBuilder(classOf[DefaultWithOption]).buildDefault() 61 | tt.w should be(None) 62 | new ClpReflectiveBuilder(classOf[NoParams]).buildDefault() 63 | new ClpReflectiveBuilder(classOf[ComplexDefault]).buildDefault() 64 | } 65 | 66 | it should "instantiate a case-class with arguments" in { 67 | val t = new ClpReflectiveBuilder(classOf[IntDefault]).build(List(3)) 68 | t.v shouldBe 3 69 | val tt = new ClpReflectiveBuilder(classOf[DefaultWithOption]).build(List(None)) 70 | tt.w shouldBe Symbol("empty") 71 | new ClpReflectiveBuilder(classOf[NoParams]).build(Nil) 72 | new ClpReflectiveBuilder(classOf[ComplexDefault]).build(List(StringDefault())) 73 | } 74 | 75 | it should "throw an exception when arguments are missing when trying to instantiate a case-class" in { 76 | an[IllegalArgumentException] should be thrownBy new ClpReflectiveBuilder(classOf[ComplexDefault]).build(Nil) 77 | } 78 | 79 | it should "work with non-case classes" in { 80 | val t = new ClpReflectiveBuilder(classOf[NotCaseClass]).build(Seq(12, 456.asInstanceOf[Long], Option("Hello"))) 81 | t.i shouldBe 12 82 | t.l shouldBe 456 83 | t.o shouldBe Some("Hello") 84 | } 85 | 86 | it should "work with constructor parameters that are not vals or vars " in { 87 | val t = new ClpReflectiveBuilder(classOf[ParamsNotVals]).build(Seq(911, 999)) 88 | t.x shouldBe 911 89 | t.y shouldBe 999 90 | } 91 | 92 | it should "work with a secondary constructor with @arg annotations in it " in { 93 | val t = new ClpReflectiveBuilder(classOf[SecondaryConstructor1]).build(Seq(50)) 94 | t.x shouldBe 50 95 | t.y shouldBe 100 96 | } 97 | 98 | it should "throw an IllegalStateException if there is no constructor with an @arg annotation" in { 99 | an[IllegalStateException] should be thrownBy new ClpReflectiveBuilder(classOf[NoAnnotation]) 100 | } 101 | 102 | it should "throw an CommandLineParserInternalException when arguments have the same name" in { 103 | an[CommandLineParserInternalException] should be thrownBy new ClpReflectiveBuilder(classOf[ConflictingNames]) 104 | } 105 | 106 | it should "throw an IllegalStateException when minElements or maxElements are given on a non-collection argument" in { 107 | val t = new ClpReflectiveBuilder(classOf[IntNoDefault]) 108 | t.argumentLookup.iterator.foreach { arg => 109 | an[IllegalStateException] should be thrownBy arg.minElements 110 | an[IllegalStateException] should be thrownBy arg.maxElements 111 | } 112 | } 113 | 114 | it should "have informative error messages with the wrong Enum value" in { 115 | val builder = new ClpReflectiveBuilder(classOf[LogLevelEnum]) 116 | val arg = builder.argumentLookup.forArg("log-level").get 117 | val exception = intercept[Exception] { arg.setArgument("NotALogLevel") } 118 | exception.getMessage.count(_ == '\n') should be > 0 // At least two lines, from a chained exception 119 | // make sure all enums are listed 120 | exception.getMessage should include ("log-level") 121 | LogLevel.values().foreach { level => exception.getMessage should include (level.name()) } 122 | } 123 | 124 | "ClpReflectiveBuilder.toCommandLineString" should "throw an IllegalStateException when trying to print an argument with no value" in { 125 | val t = new ClpReflectiveBuilder(classOf[IntNoDefault]) 126 | t.argumentLookup.iterator.foreach { 127 | an[IllegalStateException] should be thrownBy _.toCommandLineString 128 | } 129 | } 130 | 131 | it should "print v if the argument type was an Option and the value was Some(v)" in { 132 | val t = new ClpReflectiveBuilder(classOf[DefaultWithSomeOption]) 133 | t.argumentLookup.iterator.foreach { 134 | _.toCommandLineString shouldBe "--w 4" 135 | } 136 | } 137 | 138 | it should "print the special none token for a None value" in { 139 | val t = new ClpReflectiveBuilder(classOf[DefaultWithOption]) 140 | t.argumentLookup.iterator.foreach { 141 | _.toCommandLineString shouldBe s"--w ${ReflectionUtil.SpecialEmptyOrNoneToken}" 142 | } 143 | } 144 | 145 | it should "print the special empty token for an empty collection" in { 146 | val t = new ClpReflectiveBuilder(classOf[NoValuesInCollection]) 147 | t.argumentLookup.iterator.foreach { 148 | _.toCommandLineString shouldBe s"--v ${ReflectionUtil.SpecialEmptyOrNoneToken}" 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/test/scala/com/fulcrumgenomics/sopt/util/MarkDownProcessorTest.scala: -------------------------------------------------------------------------------- 1 | package com.fulcrumgenomics.sopt.util 2 | 3 | class MarkDownProcessorTest extends UnitSpec { 4 | val processor = new MarkDownProcessor() 5 | 6 | def toText(doc: String): String = processor.toText(processor.parse(doc)).mkString("\n") 7 | 8 | 9 | "MarkDownProcessor.toTree" should "print the tree structure of a simple document" in { 10 | val doc = processor.parse( 11 | """ 12 | |A paragraph 13 | | 14 | |1. An ordered list item 15 | | - An unordered list item 16 | """.stripMargin) 17 | val tree = processor.toTree(doc) 18 | 19 | val expected = 20 | """ 21 | |Document 22 | | Paragraph 23 | | Text 24 | | OrderedList 25 | | OrderedListItem 26 | | Paragraph 27 | | Text 28 | | BulletList 29 | | BulletListItem 30 | | Paragraph 31 | | Text 32 | """.stripMargin 33 | 34 | tree.trim shouldBe expected.trim 35 | } 36 | 37 | "MarkDownProcessor.toText" should "emit a simple paragraph" in { 38 | toText("Hello World!") shouldBe "Hello World!" 39 | } 40 | 41 | it should "unwrap overly wrapped text" in { 42 | toText("Hello\nBrave\nWorld!") shouldBe "Hello Brave World!" 43 | } 44 | 45 | it should "emit an ordered list with correct numbering" in { 46 | toText("1. Hello\n 1. Goodbye\n 1. Whatever") shouldBe " 1. Hello\n 2. Goodbye\n 3. Whatever" 47 | } 48 | 49 | it should "emit an unordered list" in { 50 | toText("- Hello\n- Who are you?\n- So Long!") shouldBe " * Hello\n * Who are you?\n * So Long!" 51 | } 52 | 53 | it should "remove inline formatting characters and re-wrap appropriately" in { 54 | val markdown = "This _is_ some text that's **over** `80` chars *long* with formatting but fits without." 55 | val expected = "This is some text that's over '80' chars long with formatting but fits without." 56 | toText(markdown) shouldBe expected 57 | } 58 | 59 | it should "reformat links sensibly" in { 60 | val markdown = "See the [readme](../Readme.md) for the project." 61 | val expected = "See the readme (../Readme.md) for the project." 62 | toText(markdown) shouldBe expected 63 | } 64 | 65 | it should "re-layout hard-wrapped lines but not merge paragraphs" in { 66 | val markdown = 67 | """ 68 | |This is a 69 | |silly little 70 | |paragraph! 71 | | 72 | |```scala 73 | |val foo = 2 74 | |val bar = 2 * foo 75 | |``` 76 | | 77 | |This is another paragraph 78 | |that is wrapped. 79 | | 80 | |And one last paragraph. 81 | """.stripMargin 82 | 83 | val expected = 84 | """ 85 | |This is a silly little paragraph! 86 | | 87 | | val foo = 2 88 | | val bar = 2 * foo 89 | | 90 | |This is another paragraph that is wrapped. 91 | | 92 | |And one last paragraph. 93 | """.stripMargin 94 | 95 | toText(markdown) shouldBe expected.trim 96 | } 97 | 98 | it should "create underlined headings" in { 99 | val markdown = 100 | """ 101 | |# Heading 1 102 | | 103 | |## Second level heading 104 | | 105 | |### Third is the best! 106 | """.stripMargin 107 | 108 | val expected = 109 | """ 110 | |Heading 1 111 | |========= 112 | | 113 | |Second level heading 114 | |-------------------- 115 | | 116 | |Third is the best! 117 | |------------------ 118 | """.stripMargin 119 | 120 | toText(markdown) shouldBe expected.trim 121 | } 122 | 123 | it should "correctly display a block-quote" in { 124 | val markdown = 125 | """ 126 | |> this is a fairly long block quote that will need 127 | |> to be re-wrapped but ultimately will display over 128 | |> multiple lines anyway. 129 | """.stripMargin 130 | 131 | val expected = 132 | """ 133 | |> this is a fairly long block quote that will need to be re-wrapped but 134 | |> ultimately will display over multiple lines anyway. 135 | """.stripMargin 136 | 137 | toText(markdown) shouldBe expected.trim 138 | } 139 | 140 | it should "respect the line width and indent" in { 141 | val proc = new MarkDownProcessor(lineLength=40, indentSize=4) 142 | val markdown = 143 | """ 144 | |1. This is a fairly simple ordered list with some items in it 145 | | 1. A sub-list should re-start at one 146 | | 1. So there's that 147 | | 1. And again! This is going to wrap nicely I think! 148 | |1. And back out again 149 | """.stripMargin 150 | 151 | val expected = 152 | """ 153 | | 1. This is a fairly simple ordered 154 | | list with some items in it 155 | | 1. A sub-list should re-start 156 | | at one 157 | | 2. So there's that 158 | | 1. And again! This is going 159 | | to wrap nicely I think! 160 | | 2. And back out again 161 | """.trim.stripMargin 162 | 163 | val actual = proc.toText(proc.parse(markdown)).mkString("\n") 164 | actual shouldBe expected 165 | } 166 | 167 | it should "handle markdown with words longer than line length" in { 168 | val proc = new MarkDownProcessor(lineLength=40, indentSize=4) 169 | val markdown = 170 | """ 171 | |This is a line. 172 | |ThisIsALineWithNoSpacesThatIsFarTooLongButShouldStillBeEmittedOnASingleLineAndNotFail 173 | |This is another line. 174 | """.trim.stripMargin 175 | 176 | proc.toText(proc.parse(markdown)) shouldBe markdown.linesIterator.toSeq 177 | } 178 | 179 | "MarkDownProcessor.toText" should "convert markdown to text ignoring trailing terminal codes" in { 180 | val proc = new MarkDownProcessor(lineLength=40) 181 | val sb = new StringBuilder() 182 | sb.append(KGRN("--remove-alignment-information[[=true|false]]")) 183 | sb.append(" ") 184 | sb.append(KCYN("Remove all alignment information (as well as secondary and supplementary records. " + KGRN("[[Default: false]]."))) 185 | val markdown = KCYN(sb.toString) 186 | val lines = proc.toText(proc.parse(markdown)) 187 | val processedMarkdown = markdown.replaceAll("\\[\\[", "[").replaceAll("\\]\\]", "]") 188 | lines.mkString(" ") shouldBe processedMarkdown 189 | } 190 | 191 | "MarkDownProcessor.toHtml" should "convert markdown to HTML" in { 192 | val markdown = 193 | """ 194 | |# Hello 195 | | 196 | |This is a paragraph! 197 | """.stripMargin 198 | 199 | val expected = 200 | """ 201 | |

Hello

202 | |

This is a paragraph!

203 | """.stripMargin.trim 204 | 205 | val html = processor.toHtml(processor.parse(markdown)).trim 206 | html shouldBe expected 207 | } 208 | 209 | "MarkDownProcessor.trim" should "trim the first non-ansi-escape-code non-whitespace characters" in { 210 | MarkDownProcessor.trim(KRED(KGRN(" A BCDEF G"))) shouldBe KRED(KGRN("A BCDEF G")) 211 | MarkDownProcessor.trim(KRED(KGRN("A BCDEF G"))) shouldBe KRED(KGRN("A BCDEF G")) 212 | MarkDownProcessor.trim(KRED(" ABCDEFG")) shouldBe KRED("ABCDEFG") 213 | MarkDownProcessor.trim(" ABCDEFG") shouldBe "ABCDEFG" 214 | } 215 | 216 | it should "trim the last non-ansi-escape-code whitespace characters" in { 217 | MarkDownProcessor.trim(KRED(KGRN("A BCDEF G "))) shouldBe KRED(KGRN("A BCDEF G")) 218 | MarkDownProcessor.trim(KRED(KGRN("A BCDEF G"))) shouldBe KRED(KGRN("A BCDEF G")) 219 | MarkDownProcessor.trim(KRED("ABCDEFG ")) shouldBe KRED("ABCDEFG") 220 | MarkDownProcessor.trim("ABCDEFG ") shouldBe "ABCDEFG" 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/test/scala/com/fulcrumgenomics/sopt/cmdline/ClpArgumentDefinitionPrintingTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | * 24 | */ 25 | 26 | package com.fulcrumgenomics.sopt.cmdline 27 | 28 | import java.util 29 | 30 | import com.fulcrumgenomics.sopt.util.TermCode 31 | import com.fulcrumgenomics.sopt.util.UnitSpec 32 | import org.scalatest.BeforeAndAfterAll 33 | 34 | // Sealed case class hierarchies for testing possibleValues() 35 | sealed trait Foo 36 | case object Hi extends Foo 37 | case object Lo extends Foo 38 | case object Whee extends Foo 39 | 40 | sealed trait Bar 41 | case object Alice extends Bar 42 | case object Bob extends Bar 43 | case object Eve extends Bar 44 | object Bar { 45 | def values: Seq[Bar] = Seq(Alice, Eve, Bob) 46 | } 47 | 48 | sealed trait FooBar 49 | case object Jack extends FooBar 50 | case object Jill extends FooBar 51 | case object John extends FooBar 52 | object FooBar { 53 | def findValues: Seq[FooBar] = Seq(John, Jill, Jack) 54 | } 55 | 56 | /** 57 | * Tests for ClpArgumentDefinitionPrinting. 58 | */ 59 | class ClpArgumentDefinitionPrintingTest extends UnitSpec with BeforeAndAfterAll { 60 | 61 | import com.fulcrumgenomics.sopt.cmdline.ClpArgumentDefinitionPrinting.{makeDefaultValueString, ArgumentOptionalValue} 62 | 63 | private var printColor = true 64 | 65 | override protected def beforeAll(): Unit = { 66 | printColor = TermCode.printColor 67 | TermCode.printColor = false 68 | } 69 | 70 | override protected def afterAll(): Unit = { 71 | TermCode.printColor = printColor 72 | } 73 | 74 | "ClpArgumentDefinitionPrinting.makeDefaultValueString" should "print the default value" in { 75 | makeDefaultValueString(None) shouldBe s"[[$ArgumentOptionalValue]]." 76 | makeDefaultValueString(Some(None)) shouldBe s"[[$ArgumentOptionalValue]]." 77 | makeDefaultValueString(Some(Nil)) shouldBe s"[[$ArgumentOptionalValue]]." 78 | makeDefaultValueString(Some(Set.empty)) shouldBe s"[[$ArgumentOptionalValue]]." 79 | makeDefaultValueString(Some(new util.ArrayList[java.lang.Integer]())) shouldBe s"[[$ArgumentOptionalValue]]." 80 | makeDefaultValueString(Some(Some("Value"))) shouldBe "[[Default: Value]]." 81 | makeDefaultValueString(Some("Value")) shouldBe "[[Default: Value]]." 82 | makeDefaultValueString(Some(Some(Some("Value")))) shouldBe "[[Default: Some(Value)]]." 83 | makeDefaultValueString(Some(List("A", "B", "C"))) shouldBe "[[Default: A, B, C]]." 84 | } 85 | 86 | it should "print non-printing characters as human readable defaults" in { 87 | makeDefaultValueString(Some('\t')) shouldBe """[[Default: \t]].""" 88 | makeDefaultValueString(Some('\b')) shouldBe """[[Default: \b]].""" 89 | makeDefaultValueString(Some('\n')) shouldBe """[[Default: \n]].""" 90 | makeDefaultValueString(Some('\r')) shouldBe """[[Default: \r]].""" 91 | makeDefaultValueString(Some('\f')) shouldBe """[[Default: \f]].""" 92 | } 93 | 94 | it should "print non-printing strings as human readable defaults" in { 95 | makeDefaultValueString(Some("\t\t")) shouldBe """[[Default: \t\t]].""" 96 | makeDefaultValueString(Some("\r\n")) shouldBe """[[Default: \r\n]].""" 97 | } 98 | 99 | it should "print optional non-printing characters as human readable defaults" in { 100 | makeDefaultValueString(Some(Some('\t'))) shouldBe """[[Default: \t]].""" 101 | makeDefaultValueString(Some(Some('\n'))) shouldBe """[[Default: \n]].""" 102 | } 103 | 104 | it should "print optional non-printing strings as human readable defaults" in { 105 | makeDefaultValueString(Some(Some("\t"))) shouldBe """[[Default: \t]].""" 106 | makeDefaultValueString(Some(Some("\r\n"))) shouldBe """[[Default: \r\n]].""" 107 | } 108 | 109 | it should "print optional non-printing characters in options as human readable defaults" in { 110 | makeDefaultValueString(Some(Some(Some('\t')))) shouldBe """[[Default: Some(\t)]].""" 111 | makeDefaultValueString(Some(Some(Some('\n')))) shouldBe """[[Default: Some(\n)]].""" 112 | } 113 | 114 | it should "print optional non-printing strings in options as human readable defaults" in { 115 | makeDefaultValueString(Some(Some(Some("\t")))) shouldBe """[[Default: Some(\t)]].""" 116 | makeDefaultValueString(Some(Some(Some("\r\n")))) shouldBe """[[Default: Some(\r\n)]].""" 117 | } 118 | 119 | it should "print a collection of non-printing characters as human readable defaults" in { 120 | makeDefaultValueString(Some(Seq('\t', '\r', '\b'))) shouldBe """[[Default: \t, \r, \b]].""" 121 | } 122 | 123 | it should "print a collection of non-printing strings as human readable defaults" in { 124 | makeDefaultValueString(Some(Seq("\t\t", "\r\n", "\b\b"))) shouldBe """[[Default: \t\t, \r\n, \b\b]].""" 125 | } 126 | 127 | private def printArgumentUsage(name: String, shortName: Option[Char], theType: String, 128 | collectionDescription: Option[String], argumentDescription: String, 129 | optional: Boolean = false): String = { 130 | val stringBuilder = new StringBuilder 131 | ClpArgumentDefinitionPrinting.printArgumentUsage(stringBuilder=stringBuilder, name, shortName, theType, collectionDescription, argumentDescription, optional) 132 | stringBuilder.toString 133 | } 134 | 135 | // NB: does not test column wrapping 136 | "ClpArgumentDefinitionPrinting.printArgumentUsage" should "print usages" in { 137 | val longName = "long-name" 138 | val shortName = Option('s') 139 | val theType = "TheType" 140 | val description = "Some description" 141 | 142 | printArgumentUsage(longName, shortName, theType, None, description).startsWith(s"-${shortName.get} $theType, --$longName=$theType") shouldBe true 143 | printArgumentUsage(longName, shortName, "Boolean", None, description).startsWith(s"-${shortName.get} [[true|false]], --$longName[[=true|false]]") shouldBe true 144 | printArgumentUsage(longName, None , theType, None, description).startsWith(s"--$longName=$theType") shouldBe true 145 | printArgumentUsage(longName, shortName, theType, Some("+"), description).startsWith(s"-${shortName.get} $theType+, --$longName=$theType+") shouldBe true 146 | printArgumentUsage(longName, shortName, theType, Some("{0,20}"), description).startsWith(s"-${shortName.get} $theType{0,20}, --$longName=$theType{0,20}") shouldBe true 147 | } 148 | 149 | "ClpArgumentDefinitionPrinting.possibleValues" should "find the possible values in a sealed trait/case object hierarchy" in { 150 | ClpArgumentDefinitionPrinting.possibleValues(classOf[Foo]) shouldBe "Options: Hi, Lo, Whee." 151 | } 152 | 153 | it should "find the possible values in a sealed trait/case object hierarchy with a values method" in { 154 | ClpArgumentDefinitionPrinting.possibleValues(classOf[Bar]) shouldBe "Options: Alice, Eve, Bob." 155 | ClpArgumentDefinitionPrinting.possibleValues(classOf[FooBar]) shouldBe "Options: John, Jill, Jack." 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/main/scala/com/fulcrumgenomics/sopt/Sopt.scala: -------------------------------------------------------------------------------- 1 | package com.fulcrumgenomics.sopt 2 | 3 | import com.fulcrumgenomics.sopt.cmdline.{ClpArgumentDefinitionPrinting, ClpGroup, CommandLineParser, CommandLineProgramParser} 4 | import com.fulcrumgenomics.sopt.util.{MarkDownProcessor, ParsingUtil} 5 | import com.sun.org.apache.xpath.internal.Arg 6 | 7 | import scala.reflect.runtime.universe.TypeTag 8 | import scala.reflect.ClassTag 9 | 10 | /** 11 | * Facade into Sopt that allows for both parsing of command lines to generate command objects 12 | * and also inspection of command classes. 13 | */ 14 | object Sopt { 15 | /** The assumed width of the terminal. */ 16 | val TerminalWidth: Int = 120 17 | 18 | /** MarkDown processor used to render text and HTML versions of descriptions. */ 19 | private val markDownProcessor = new MarkDownProcessor(TerminalWidth) 20 | 21 | /** Trait representing the results of trying to parse commands, and sub-commands, in sopt. */ 22 | sealed trait Result[Command,Subcommand] 23 | /** The result type when a single command is successfully parsed. */ 24 | case class CommandSuccess[A](command: A) extends Result[A,Nothing] 25 | /** The result type when a command/sub-command pair are successfully parsed. */ 26 | case class SubcommandSuccess[A,B](command: A, subcommand: B) extends Result[A, B] 27 | /** The result type when a parsing failure occurrs. */ 28 | case class Failure(usage: () => String) extends Result[Nothing,Nothing] 29 | 30 | protected trait MarkDownDescription { 31 | /** The description as MarkDown text. */ 32 | def description: String 33 | 34 | /** Returns the description as plain text, line-wrapped to [[TerminalWidth]]. */ 35 | def descriptionAsText: String = markDownProcessor.toText(markDownProcessor.parse(description)).mkString("\n") 36 | 37 | /** Returns the description as HTML. */ 38 | def descriptionAsHtml: String = markDownProcessor.toHtml(markDownProcessor.parse(description)) 39 | } 40 | 41 | /** Represents the group to which command line programs belong. */ 42 | case class Group(name: String, description: String, override val rank: Int) extends ClpGroup 43 | 44 | /** 45 | * Represents the metadata about a command line program that may be consumed externally to generate 46 | * documentation etc. 47 | * 48 | * @param name the name of the program / command 49 | * @param group the clp group name of the program 50 | * @param hidden whether or not the program is marked as hidden 51 | * @param description the description/documentation for the program in MarkDown format 52 | * @param args the ordered [[Seq]] of arguments the program takes 53 | */ 54 | case class ClpMetadata(name: String, 55 | group: Group, 56 | hidden: Boolean, 57 | description: String, 58 | args: Seq[Arg] 59 | ) extends MarkDownDescription 60 | 61 | /** 62 | * Represents information about an argument to a command line program. 63 | * 64 | * @param name the name of the argument as presented on the command line 65 | * @param group the (optional) name of the group in which the argument exists 66 | * @param flag the optional flag character for the argument if it has one 67 | * @param kind the name of the type of the argument (for collection arguments, the type in the collection) 68 | * @param minValues the minimum number of values that must be specified 69 | * @param maxValues the maximum number of values that may be specified 70 | * @param defaultValues the seq of default values, as strings 71 | * @param sensitive if true the argument is sensitive and values should not be re-displayed 72 | * @param description the description of the argument 73 | */ 74 | case class Arg(name: String, 75 | group: Option[String], 76 | flag: Option[Char], 77 | kind: String, 78 | minValues: Int, 79 | maxValues: Int, 80 | defaultValues: Seq[String], 81 | sensitive: Boolean, 82 | description: String 83 | ) extends MarkDownDescription 84 | 85 | /** Finds classes that extend the given type within the specified packages. 86 | * 87 | * @param packages one or more fully qualified packages (e.g. com.fulcrumgenomics.sopt') 88 | * @param includeHidden whether or not to include programs marked as hidden 89 | * @tparam A the type of the commands to find 90 | * @return the resulting set of command classes 91 | */ 92 | def find[A : ClassTag : TypeTag](packages: Iterable[String], includeHidden: Boolean = false): Seq[Class[_ <: A]] = { 93 | ParsingUtil.findClpClasses[A](packages.toList, includeHidden=includeHidden).keys.toSeq 94 | } 95 | 96 | /** 97 | * Inspect a command class that is annotated with [[clp]] and [[arg]] annotations. 98 | * 99 | * @param clp the class to be inspected 100 | * @tparam A the type of the class 101 | * @return a metadata object containing information about the command and it's arguments 102 | */ 103 | def inspect[A](clp: Class[A]): ClpMetadata = { 104 | val parser = new CommandLineProgramParser(clp, includeSpecialArgs=false) 105 | val clpAnn = ParsingUtil.findClpAnnotation(clp).getOrElse(throw new IllegalStateException("No @clp on " + clp.getName)) 106 | val args = parser.argumentLookup.ordered.filter(_.annotation.isDefined).map ( a => Arg( 107 | name = a.longName, 108 | group = a.groupName, 109 | flag = a.shortName, 110 | kind = a.typeName, 111 | minValues = if (a.isCollection) a.minElements else if (a.optional) 0 else 1, 112 | maxValues = if (a.isCollection) a.maxElements else 1, 113 | defaultValues = ClpArgumentDefinitionPrinting.defaultValuesAsSeq(a.defaultValue), 114 | sensitive = a.isSensitive, 115 | description = a.doc 116 | )) 117 | 118 | val group = clpAnn.group().newInstance() 119 | 120 | ClpMetadata( 121 | name = clp.getSimpleName, 122 | group = Group(name=group.name, description=group.description, rank=group.rank), 123 | hidden = clpAnn.hidden(), 124 | description = clpAnn.description().stripMargin.trim().dropWhile(_ == '\n'), 125 | args = args 126 | ) 127 | } 128 | 129 | /** 130 | * Parses a command line for a single command. Expects that the command name is the first 131 | * value in the arguments, and that the remainder are arguments to the command. 132 | * 133 | * @param name the name of the toolkit, to be printed in usage statements 134 | * @param args the ordered sequence of arguments from the command line 135 | * @param commands the set of possible command classes to select from 136 | * @tparam Command the parent type of all command classes 137 | * @return the result of parsing the command 138 | */ 139 | def parseCommand[Command: TypeTag : ClassTag](name: String, 140 | args: Seq[String], 141 | commands: Iterable[Class[_ <: Command]]): Result[_ <: Command,Nothing] = { 142 | new CommandLineParser[Command](name)parseSubCommand(args, commands) 143 | } 144 | 145 | /** 146 | * Parses a command line for a command/sub-command pair. The arguments should contain, in order, 147 | * any arguments to the primary command, the name of the sub-command, and then the arguments 148 | * to the sub-command. 149 | * 150 | * @param name the name of the toolkit, to be printed in usage statements 151 | * @param args the ordered sequence of arguments from the command line 152 | * @param subcommands the set of possible subcommand classes to select from 153 | * @tparam Command the type of the command class 154 | * @tparam SubCommand the parent type of all the subcommands 155 | * @return the result of parsing the command 156 | */ 157 | def parseCommandAndSubCommand[Command:TypeTag:ClassTag, SubCommand:TypeTag:ClassTag ] 158 | (name: String, args: Seq[String], subcommands: Iterable[Class[_ <: SubCommand]]): Result[_ <: Command, _ <: SubCommand] = { 159 | new CommandLineParser[SubCommand](name).parseCommandAndSubCommand[Command](args, subcommands) 160 | } 161 | } 162 | 163 | 164 | -------------------------------------------------------------------------------- /src/main/scala/com/fulcrumgenomics/sopt/parsing/ArgTokenizer.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics.sopt.parsing 26 | 27 | import java.nio.file.{Files, Paths} 28 | import java.util.NoSuchElementException 29 | 30 | import com.fulcrumgenomics.commons.CommonsDef._ 31 | 32 | import scala.collection.mutable 33 | import scala.util.{Failure, Success, Try} 34 | 35 | object ArgTokenizer { 36 | /** The token returned by the tokenizer */ 37 | sealed abstract class Token 38 | /** An option-name-only token */ 39 | case class ArgOption(name: String) extends Token 40 | /** A value-only token */ 41 | case class ArgValue(value: String) extends Token 42 | /** An option-name-and-value token */ 43 | case class ArgOptionAndValue(name: String, value: String) extends Token 44 | 45 | private[parsing] def addBackDashes(name: String): String = if (name.length == 1) s"-$name" else s"--$name" 46 | } 47 | 48 | /** A class to tokenize a sequence of strings prior to option parsing. A series of tokens will 49 | * be available. If any errors are encountered, a failure will be present instead of the token. */ 50 | class ArgTokenizer(args: Seq[String], 51 | val argFilePrefix: Option[String] = None) extends Iterator[Try[ArgTokenizer.Token]] { 52 | import ArgTokenizer._ 53 | 54 | private var nextToken: Option[Try[Token]] = None 55 | private val stack = mutable.Stack[String](args:_*) 56 | 57 | /** Alternate constructor that supports var-arg syntax for testing. */ 58 | private[sopt] def this(args: String*) = this(args.toSeq, None) 59 | 60 | /** True if there are more tokens, false otherwise */ 61 | override def hasNext(): Boolean = { 62 | if (nextToken.isEmpty) updateNextToken() 63 | nextToken.isDefined 64 | } 65 | 66 | /** Returns the next token, or a failure if one was encountered. */ 67 | override def next: Try[Token] = { 68 | // bad user 69 | if (!hasNext()) throw new NoSuchElementException("Called 'next' when 'hasNext' is false") 70 | val tryVal = nextToken match { 71 | case None => throw new NoSuchElementException("Called 'next' when 'hasNext' is false") 72 | case Some(value) => value 73 | } 74 | nextToken = None 75 | tryVal 76 | } 77 | 78 | /** Returns the current token, or a failure if one was encountered. Does not move to the next token. */ 79 | def head: Try[Token] = { 80 | if (!hasNext()) throw new NoSuchElementException("Called 'next' when 'hasNext' is false") 81 | nextToken match { 82 | case None => throw new NoSuchElementException("Called 'next' when 'hasNext' is false") 83 | case Some(value) => value 84 | } 85 | } 86 | 87 | /** Returns any remaining args that were not tokenized. This is a destructive operation, so should only be called once. */ 88 | def takeRemaining: Seq[String] = { 89 | // add back the last token to the remaining tokens 90 | nextToken match { 91 | case Some(Success(ArgOption(name))) => this.stack.push(ArgTokenizer.addBackDashes(name)) 92 | case Some(Success(ArgValue(value))) => this.stack.push(value) 93 | case Some(Success(ArgOptionAndValue(name, value))) => this.stack.push(ArgTokenizer.addBackDashes(name), value) 94 | case _ => 95 | } 96 | nextToken = None 97 | this.stack.toSeq 98 | } 99 | 100 | /** Sets the `nextToken` if it is not defined and we have more strings in the iterator */ 101 | private def updateNextToken(): Unit = if (nextToken.isEmpty && this.stack.nonEmpty) nextToken = takeNextToken 102 | 103 | /** 104 | * Requires that the stack have at least one item left in it; pulls the top item from the 105 | * stack and processes it. 106 | */ 107 | private def takeNextToken: Option[Try[Token]] = { 108 | val nextArg = this.stack.pop() // save in case we need to push it back on the stack in case of failure 109 | val nextToken = (nextArg, argFilePrefix) match { 110 | case ("--", _) => None 111 | case ("", _) => Some(Failure(new OptionNameException("Empty argument given."))) 112 | case (arg, _) if arg.startsWith("--") => Some(convertDoubleDashOption(arg.substring(2))) 113 | case (arg, _) if arg.startsWith("-") => Some(convertSingleDash(arg.substring(1))) 114 | case (arg, Some(pre)) if arg.startsWith(pre) => 115 | loadArgumentFile(arg.drop(pre.length)) match { 116 | case Failure(failure) => Some(Failure(failure)) 117 | case Success(newArgs) => 118 | this.stack.pushAll(newArgs.reverseIterator) 119 | if (this.stack.nonEmpty) takeNextToken else None 120 | } 121 | case (arg, _) => Some(Success(ArgValue(value = arg))) 122 | } 123 | // push back the arg on the stack if there was any failure 124 | nextToken match { 125 | case Some(Failure(failure)) => this.stack.push(nextArg) 126 | case Some(Success(_)) | None => 127 | } 128 | nextToken 129 | } 130 | 131 | /** Loads an arguments file and returns the list of tokens it contains. */ 132 | private def loadArgumentFile(filename: String): Try[Seq[String]] = { 133 | Try { Files.readAllLines(Paths.get(filename)).map(s => s.trim).filter(s => s.nonEmpty).toSeq } 134 | } 135 | 136 | /** If the arg was an option (leading dash or dashes) but has no characters after the dash, create an appropriate 137 | * exception wrapped in a failure. */ 138 | private def emptyFailure(arg: String): Try[Token] = { 139 | Failure(OptionNameException(s"Option names must have at least one character after the leading dash; found: '$arg'")) 140 | } 141 | 142 | /** Given an short name option string without the leading dash, returns the option name, and optionally 143 | * a value for that option if one exists. The latter may happen if the short name and value are concatenated or 144 | * separated by an '=' character. 145 | */ 146 | private def convertSingleDash(input: String): Try[Token] = { 147 | if (input.isEmpty) emptyFailure(input) 148 | else { 149 | val name = input.substring(0, 1) 150 | input.substring(1) match { 151 | case "" => Success(ArgOption(name = name)) 152 | // NB: must have characters after the "=" 153 | case value if value.startsWith("=") && value.length > 1 => Success(ArgOptionAndValue(name = name, value = value.substring(1))) 154 | case value => Success(ArgOptionAndValue(name = name, value = value)) 155 | } 156 | } 157 | } 158 | 159 | /** Given an long name option string without the leading dashes, and returns the option name, and optionally 160 | * a value for that option if one exists. The latter may happen if the long name and value are separated by an '=' 161 | * character. If no value is give after the "=", the a failure is returned. 162 | */ 163 | private def convertDoubleDashOption(input: String): Try[Token] = { 164 | val idx = input.indexOf('=') 165 | (input.take(idx), input.drop(idx+1)) match { 166 | case (before, after) if before.isEmpty => Success(ArgOption(name = after)) 167 | case (before, after) if after.isEmpty => Failure(new OptionNameException(s"Trailing '=' found in option '$before'; did you forget a value?")) 168 | case (before, after) => Success(ArgOptionAndValue(name = before, value = after)) 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/main/scala/com/fulcrumgenomics/sopt/util/ParsingUtil.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics.sopt.util 26 | 27 | import java.lang.reflect.Modifier 28 | 29 | import com.fulcrumgenomics.commons.CommonsDef._ 30 | import com.fulcrumgenomics.commons.reflect.ReflectionUtil 31 | import com.fulcrumgenomics.commons.util.{ClassFinder, StringUtil} 32 | import com.fulcrumgenomics.sopt.cmdline.CommandLineException 33 | import com.fulcrumgenomics.sopt.clp 34 | 35 | import scala.collection.immutable.Map 36 | import scala.collection.mutable 37 | import scala.reflect._ 38 | import scala.reflect.runtime.universe._ 39 | 40 | /** Variables and Methods to support command line parsing */ 41 | object ParsingUtil { 42 | /** Gets the [[clp]] annotation on this class */ 43 | def findClpAnnotation(clazz: Class[_]): Option[clp] = { 44 | ReflectionUtil.findJavaAnnotation(clazz, classOf[clp]) 45 | } 46 | 47 | /** Gets a mapping between sub-classes of [[T]] and their [[clp]] annotation. 48 | * 49 | * @param srcClasses the source classes from which to build the map. 50 | * @param omitSubClassesOf a list of omit subclasses to omit from the returned map. 51 | * @param includeHidden include classes whose [[clp]] annotation has `hidden` set to true. 52 | * @tparam T the super class. 53 | * @return a mapping between sub-classes of [[T]] and their [[clp]] annotation. 54 | */ 55 | private def classToAnnotationMapFromSourceClasses[T](srcClasses: Iterable[Class[_]], 56 | omitSubClassesOf: Iterable[Class[_]] = Nil, 57 | includeHidden: Boolean = false) 58 | : Map[Class[_ <: T], clp] = { 59 | 60 | // Filter out interfaces, synthetic, primitive, local, or abstract classes. 61 | def keepClass(clazz: Class[_]): Boolean = { 62 | !clazz.isInterface && !clazz.isSynthetic && !clazz.isPrimitive && !clazz.isLocalClass && !Modifier.isAbstract(clazz.getModifiers) 63 | } 64 | 65 | // Find all classes with the annotation 66 | val classes: Iterable[Class[T]] = srcClasses 67 | .filter { keepClass } 68 | .filterNot { clazz => omitSubClassesOf.exists { _.isAssignableFrom(clazz) } } 69 | .filter { 70 | findClpAnnotation(_) match { 71 | case None => false 72 | case Some(clp) => includeHidden || !clp.hidden 73 | } 74 | }.map(_.asInstanceOf[Class[T]]) 75 | 76 | // Get all the name collisions 77 | val nameCollisions = classes 78 | .groupBy(_.getSimpleName) 79 | .filter { case (name, cs) => cs.size > 1 } 80 | .map { case (name, cs) => cs.mkString(", ") } 81 | 82 | // SimpleName should be unique 83 | if (nameCollisions.nonEmpty) { 84 | throw new CommandLineException(s"Simple class name collision: ${nameCollisions.mkString(", ")}") 85 | } 86 | 87 | // Finally, make the map 88 | classes.map(clazz => Tuple2(clazz, ReflectionUtil.findJavaAnnotation(clazz, classOf[clp]).get)).toMap 89 | } 90 | 91 | 92 | /** Finds all class that extends [[T]] and produces a map from a sub-class of [[T]] to its [[clp]] annotation. 93 | * 94 | * Throws a [[IllegalStateException]] if a sub-class of [[T]] is missing the [[clp]] annotation. 95 | * 96 | * @param packageList the namespace(s) to search. 97 | * @param omitSubClassesOf a list of omit subclasses to omit from the returned map. 98 | * @param includeHidden include classes whose [[clp]] annotation has `hidden` set to true. 99 | * @return map from clazz to property. 100 | */ 101 | def findClpClasses[T: ClassTag: TypeTag](packageList: List[String], 102 | omitSubClassesOf: Iterable[Class[_]] = Nil, 103 | includeHidden: Boolean = false) 104 | : Map[Class[_ <: T], clp] = { 105 | val clazz: Class[T] = ReflectionUtil.typeToClass(typeOf[T]).asInstanceOf[Class[T]] 106 | 107 | // find all classes that extend [[T]] 108 | val classFinder: ClassFinder = new ClassFinder 109 | for (pkg <- packageList) { 110 | classFinder.find(pkg, clazz) 111 | } 112 | 113 | classToAnnotationMapFromSourceClasses[T]( 114 | srcClasses = classFinder.getClasses.iterator().toSeq, 115 | omitSubClassesOf = omitSubClassesOf, 116 | includeHidden = includeHidden) 117 | } 118 | 119 | /** Finds the the smallest similarity distance between the target and options, returning `Integer.MAX_VALUE` if none was 120 | * found. 121 | */ 122 | private[sopt] def findSmallestSimilarityDistance(target: String, 123 | options: Iterable[String], 124 | distances: mutable.Map[String, Integer] = new mutable.HashMap[String, Integer], 125 | unknownSimilarityFloor: Int = 7, 126 | unknownSubstringLength: Int = 5 127 | ): Int = { 128 | var bestDistance: Int = Integer.MAX_VALUE 129 | var bestN: Int = 0 130 | options.foreach{ name => 131 | if (name == target) { 132 | throw new IllegalStateException(s"BUG: Option name matches when searching for the unknown: $name") 133 | } 134 | val distance: Int = if (name.startsWith(target) || (unknownSubstringLength <= target.length && name.contains(target))) { 135 | 0 136 | } 137 | else { 138 | StringUtil.levenshteinDistance(target, name, 0, 2, 1, 4) 139 | } 140 | distances.put(name, distance) 141 | if (distance < bestDistance) { 142 | bestDistance = distance 143 | bestN = 1 144 | } 145 | else if(distance == bestDistance) { 146 | bestN += 1 147 | } 148 | } 149 | if (0 == bestDistance && 1 < bestN && bestN == options.size) Integer.MAX_VALUE else bestDistance 150 | } 151 | 152 | /** When a command does not match any known command, searches for similar commands, using the same method as GIT **/ 153 | private def findSimilar(target: String, 154 | options: Iterable[String], 155 | unknownSimilarityFloor: Int = 7, 156 | unknownSubstringLength: Int = 5 157 | ): Seq[String] = { 158 | val distances: mutable.Map[String, Integer] = new mutable.HashMap[String, Integer] 159 | val bestDistance: Int = findSmallestSimilarityDistance( 160 | target=target, 161 | options=options, 162 | distances=distances, 163 | unknownSimilarityFloor=unknownSimilarityFloor, 164 | unknownSubstringLength=unknownSubstringLength 165 | ) 166 | if (bestDistance < unknownSimilarityFloor) { 167 | options.filter(bestDistance == distances.get(_).get).toSeq 168 | } 169 | else { 170 | Seq.empty[String] 171 | } 172 | } 173 | 174 | /** Finds all options that are similar to the target and returns a string of suggestions if any were found. */ 175 | private[sopt] def printUnknown(target: String, 176 | options: Iterable[String], 177 | unknownSimilarityFloor: Int = 7, 178 | unknownSubstringLength: Int = 5): String = { 179 | findSimilar(target=target, options=options, unknownSimilarityFloor=unknownSimilarityFloor, unknownSubstringLength=unknownSubstringLength) match { 180 | case Nil => "" 181 | case suggestions => 182 | val optionSeparator = "\n " 183 | String.format("\nDid you mean %s?%s", 184 | if (suggestions.length < 2) "this" else "one of these", 185 | optionSeparator + suggestions.mkString(optionSeparator) 186 | ) 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /.github/logos/fulcrumgenomics.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main/scala/com/fulcrumgenomics/sopt/cmdline/ClpArgumentDefinitionPrinting.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics.sopt.cmdline 26 | 27 | import java.util 28 | 29 | import com.fulcrumgenomics.commons.CommonsDef._ 30 | import com.fulcrumgenomics.commons.reflect.ReflectionUtil 31 | import com.fulcrumgenomics.sopt.Sopt 32 | import com.fulcrumgenomics.sopt.util.{KCYN, KGRN, KYEL, MarkDownProcessor} 33 | 34 | import scala.util.{Failure, Success} 35 | 36 | object ClpArgumentDefinitionPrinting { 37 | /** Strings for printing enum options */ 38 | private[cmdline] val EnumOptionDocPrefix: String = "Options: " 39 | private[cmdline] val EnumOptionDocSuffix: String = "." 40 | 41 | /** Prefix for the default value for an argument. */ 42 | private[cmdline] val ArgumentDefaultValuePrefix: String = "Default:" 43 | private[cmdline] val ArgumentOptionalValue: String = "Optional" 44 | 45 | /** A collection of escaped non-printing ASCII characters and their string literals. */ 46 | private[cmdline] val EscapedNonPrintingCharacters: Map[String, String] = Map( 47 | "\t" -> """\t""", 48 | "\b" -> """\b""", 49 | "\n" -> """\n""", 50 | "\r" -> """\r""", 51 | "\f" -> """\f""", 52 | ) 53 | 54 | /** For formatting argument section of usage message. */ 55 | private val ArgumentColumnWidth: Int = 30 56 | private val DescriptionColumnWidth: Int = Sopt.TerminalWidth - ArgumentColumnWidth 57 | 58 | /** Markdown processor for formatting argument descriptions. */ 59 | private val markDownProcessor = new MarkDownProcessor(lineLength=DescriptionColumnWidth) 60 | 61 | /** Prints the usage for a given argument definition */ 62 | private[cmdline] def printArgumentDefinitionUsage(stringBuilder: StringBuilder, 63 | argumentDefinition: ClpArgument, 64 | argumentLookup: ClpArgumentLookup): Unit = { 65 | printArgumentUsage(stringBuilder, 66 | argumentDefinition.longName, 67 | argumentDefinition.shortName, 68 | argumentDefinition.typeDescription, 69 | makeCollectionArity(argumentDefinition), 70 | makeArgumentDescription(argumentDefinition, argumentLookup), 71 | argumentDefinition.optional) 72 | } 73 | 74 | def mutexErrorHeader: String = " Cannot be used in conjunction with argument(s): " 75 | 76 | /** 77 | * Makes the full description string for the argument (that goes into the description column 78 | * in the argument usage) and contains the doc from the [[com.fulcrumgenomics.sopt.arg]] annotation along 79 | * with the default value(s), list of mutually exclusive options, and in the case of enums, 80 | * possible values. 81 | */ 82 | private def makeArgumentDescription(argumentDefinition: ClpArgument, 83 | argumentLookup: ClpArgumentLookup): String = { 84 | // a secondary map where the keys are the field names 85 | val sb: StringBuilder = new StringBuilder 86 | if (argumentDefinition.doc.nonEmpty) sb.append(argumentDefinition.doc).append(" ") 87 | if (argumentDefinition.optional) sb.append(makeDefaultValueString(argumentDefinition.defaultValue)) 88 | val possibles = KGRN(possibleValues(argumentDefinition.unitType)) 89 | if (possibles.nonEmpty) sb.append(" ").append(possibles) 90 | 91 | if (argumentDefinition.mutuallyExclusive.nonEmpty) { 92 | sb.append(mutexErrorHeader) 93 | sb.append(argumentDefinition.mutuallyExclusive.map { targetFieldName => 94 | argumentLookup.forField(targetFieldName) match { 95 | case None => 96 | throw UserException(s"Invalid argument definition in source code (see mutex). " + 97 | s"$targetFieldName doesn't match any known argument.") 98 | case Some(mutex) => 99 | mutex.name + mutex.shortName.map { c => s" ($c)" }.getOrElse("") 100 | } 101 | }.mkString(", ")) 102 | } 103 | sb.toString 104 | } 105 | 106 | private def makeCollectionArity(argumentDefinition: ClpArgument): Option[String] = { 107 | if (!argumentDefinition.isCollection) return None 108 | 109 | val description = (argumentDefinition.minElements, argumentDefinition.maxElements) match { 110 | case (0, Integer.MAX_VALUE) => "*" 111 | case (1, Integer.MAX_VALUE) => "+" 112 | case (m, n) => s"{$m..$n}" 113 | } 114 | Some(description) 115 | } 116 | 117 | /** 118 | * Prints the default value if available, otherwise a message that it is not required. This assumes the argument is 119 | * optional. 120 | */ 121 | private[sopt] def makeDefaultValueString(value : Option[_]) : String = { 122 | val vs = defaultValuesAsSeq(value) 123 | // NB: extra square brackets are inserted due to one set being stripped during markdown processing 124 | KGRN(if (vs.isEmpty) s"[[$ArgumentOptionalValue]]." else s"[[$ArgumentDefaultValuePrefix ${vs.mkString(", ")}]].") 125 | } 126 | 127 | /** Converts all non-printing characters in a string into their string literal form. */ 128 | private[sopt] def nonPrintingToStringLiteral(string: String): String = { 129 | EscapedNonPrintingCharacters.foldLeft(string) { case (toModify, (char, literal)) => 130 | toModify.replaceAllLiterally(char, literal) 131 | } 132 | } 133 | 134 | /** Returns the set of default values as a Seq of Strings, one per default value. */ 135 | private [sopt] def defaultValuesAsSeq(value: Option[_]): Seq[String] = value match { 136 | case None | Some(None) | Some(Nil) => Seq.empty 137 | case Some(s) if Set.empty == s => Seq.empty 138 | case Some(c) if c.isInstanceOf[util.Collection[_]] => { 139 | c.asInstanceOf[util.Collection[_]].map(_.toString).map(nonPrintingToStringLiteral).toSeq 140 | } 141 | case Some(t) if t.isInstanceOf[Iterable[_]] => { 142 | t.asInstanceOf[Iterable[_]].map(_.toString).map(nonPrintingToStringLiteral).toSeq 143 | } 144 | case Some(Some(x)) => Seq(nonPrintingToStringLiteral(x.toString)) 145 | case Some(x) => Seq(nonPrintingToStringLiteral(x.toString)) 146 | } 147 | 148 | /** Prints the usage for a given argument given its various elements */ 149 | private[cmdline] def printArgumentUsage(stringBuilder: StringBuilder, name: String, shortName: Option[Char], theType: String, 150 | collectionArityString: Option[String], argumentDescription: String, 151 | optional: Boolean): Unit = { 152 | // Desired output: "-f Foo, --foo=Foo" and for Booleans, "-f [true|false] --foo=[true|false]" 153 | val collectionDesc = collectionArityString.getOrElse("") 154 | // NB: extra square brackets are inserted due to one set being stripped during markdown processing 155 | val (shortType, longType) = if (theType == "Boolean") ("[[true|false]]","[[=true|false]]") else (theType, "=" + theType) 156 | val label = new StringBuilder() 157 | shortName.foreach(n => label.append("-" + n + " " + shortType + collectionDesc + ", ")) 158 | label.append("--" + name + longType + collectionDesc) 159 | val colorLabel = if (optional) KGRN else KYEL 160 | stringBuilder.append(colorLabel(label.toString())) 161 | 162 | // If the label is short enough, just pad out the column, otherwise wrap to the next line for the description 163 | val numSpaces: Int = if (label.length > ArgumentColumnWidth) { 164 | stringBuilder.append("\n") 165 | ArgumentColumnWidth 166 | } 167 | else { 168 | ArgumentColumnWidth - label.length 169 | } 170 | stringBuilder.append(" " * numSpaces) 171 | 172 | val wrappedDescriptionBuilder = new StringBuilder() 173 | val md = this.markDownProcessor.parse(argumentDescription) 174 | val padding = " " * ArgumentColumnWidth 175 | val lines = this.markDownProcessor.toText(md) 176 | wrappedDescriptionBuilder.append(lines.head).append('\n') 177 | lines.tail.foreach(line => wrappedDescriptionBuilder.append(padding).append(line).append('\n')) 178 | 179 | stringBuilder.append(KCYN(wrappedDescriptionBuilder.toString())) 180 | } 181 | 182 | /** 183 | * Returns the help string with details about valid options for the given argument class. 184 | * 185 | *

186 | * Currently this only make sense with [[Enumeration]] and sealed trait hierarchies. 187 | * Any other class will result in an empty string. 188 | *

189 | * 190 | * @param clazz the target argument's class. 191 | * @return never { @code null}. 192 | */ 193 | private[cmdline] def possibleValues(clazz: Class[_]): String = { 194 | val options = { 195 | if (clazz.isEnum) { 196 | val enumClass: Class[_ <: Enum[_ <: Enum[_]]] = clazz.asInstanceOf[Class[_ <: Enum[_ <: Enum[_]]]] 197 | ReflectionUtil.enumOptions(enumClass) match { 198 | case Success(constants) => Some(constants.map(_.name)) 199 | case Failure(thr) => throw thr 200 | } 201 | } 202 | else { 203 | val symbol = scala.reflect.runtime.currentMirror.classSymbol(clazz) 204 | if (symbol.isTrait && symbol.isSealed) { 205 | ReflectionUtil.sealedTraitOptions(clazz) match { 206 | case Success(values) => Some(values) 207 | case Failure(thr) => throw thr 208 | } 209 | } 210 | else { 211 | None 212 | } 213 | } 214 | } 215 | 216 | options match { 217 | case Some(values) => values.mkString(EnumOptionDocPrefix, ", ", EnumOptionDocSuffix) 218 | case None => "" 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/test/scala/com/fulcrumgenomics/sopt/cmdline/ClpArgumentTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | package com.fulcrumgenomics.sopt.cmdline 25 | 26 | import java.nio.file.Path 27 | 28 | import com.fulcrumgenomics.commons.io.PathUtil 29 | import com.fulcrumgenomics.sopt.arg 30 | import com.fulcrumgenomics.sopt.util.UnitSpec 31 | import org.scalatest.OptionValues 32 | 33 | object ClpArgumentTest { 34 | type PathToNowhere = java.nio.file.Path 35 | case class BooleanClass(@arg var aBool: Boolean) 36 | case class PathClass(@arg var aPath: Path) 37 | case class SeqClass(@arg var aSeqOfUnknown: Seq[_]) 38 | case class SeqIntClass(@arg var aSeqOfInt: Seq[Int]) 39 | case class PathToNowhereOptionClass(@arg var anOptionPath: Option[PathToNowhere]) 40 | case class SeqWithDefaults(@arg var aSeq: Seq[_] = Seq(1, 2, 3)) 41 | case class NoAnnotation(var aVar: Boolean = false) 42 | case class NoAnnotationNoDefault(var Boolean: Int) 43 | case class LongArgDoc(@arg(doc= 44 | """This is 45 | |supposed to 46 | |be wrapped. 47 | """) foo: String) 48 | } 49 | 50 | class ClpArgumentTest extends UnitSpec with OptionValues { 51 | import ClpArgumentTest._ 52 | 53 | /** Helper function to create a single ClpArgument for a class with a single constructor arg. */ 54 | def makeClpArgument(clazz : Class[_], defaultValue: Any) : ClpArgument = { 55 | val arg = new ClpReflectiveBuilder(clazz).argumentLookup.iterator.next 56 | arg.value = defaultValue 57 | arg 58 | } 59 | 60 | "ClpArgument should" should "store a boolean field" in { 61 | val argument = makeClpArgument(classOf[BooleanClass], false) 62 | argument.isFlag should be(true) 63 | argument.value.get should be(false.asInstanceOf[Any]) 64 | argument.value.get.asInstanceOf[Boolean] should be(false) 65 | argument.typeDescription shouldBe "Boolean" 66 | argument.optional shouldBe true 67 | argument.hasValue shouldBe true 68 | // try with.value = 69 | argument.value = true 70 | argument.value.get should be(true.asInstanceOf[Any]) 71 | argument.value.get.asInstanceOf[Boolean] should be(true) 72 | // try with setArgument 73 | argument.setArgument("false") 74 | argument.value.get should be(false.asInstanceOf[Any]) 75 | argument.value.get.asInstanceOf[Boolean] should be(false) 76 | 77 | // try with setArgument with no value 78 | argument.isSetByUser = false // override 79 | argument.setArgument() 80 | argument.value.get should be(true.asInstanceOf[Any]) 81 | argument.value.get.asInstanceOf[Boolean] should be(true) 82 | // try with setArgument with one value 83 | an[IllegalStateException] should be thrownBy argument.setArgument("false") 84 | // try with multiple values 85 | argument.isSetByUser = false // override 86 | an[UserException] should be thrownBy argument.setArgument("a", "b") 87 | } 88 | 89 | it should "store a Path" in { 90 | val path = PathUtil.pathTo("a", "path") 91 | val newPath = PathUtil.pathTo("b", "path") 92 | val argument = makeClpArgument(classOf[PathClass], path) 93 | argument.isFlag should be(false) 94 | argument.value.get should be(path.asInstanceOf[Any]) 95 | argument.value.get.asInstanceOf[Path] should be(path) 96 | argument.typeDescription shouldBe "Path" 97 | // try with.value = 98 | argument.value = newPath 99 | argument.value.get should be(newPath.asInstanceOf[Any]) 100 | argument.value.get.asInstanceOf[Path] should be(newPath) 101 | // try with setArgument 102 | argument.setArgument(path.toString) 103 | argument.value.get should be(path.asInstanceOf[Any]) 104 | argument.value.get.asInstanceOf[Path] should be(path) 105 | } 106 | 107 | it should "store a Seq[_]" in { 108 | val seq = Seq(1, 2, 3) 109 | val newSeq = Seq(1, 2, 3, 4) 110 | val argument = makeClpArgument(classOf[SeqClass], seq) 111 | argument.isFlag should be(false) 112 | argument.isCollection should be(true) 113 | argument.value.get should be(seq.asInstanceOf[Any]) 114 | argument.value.get.asInstanceOf[Seq[_]] should be(seq) 115 | argument.value.get.asInstanceOf[Seq[_]](2) should be(3) 116 | argument.typeDescription shouldBe "Any" 117 | // try with.value = 118 | argument.value = newSeq 119 | argument.value.get should be(newSeq.asInstanceOf[Any]) 120 | argument.value.get.asInstanceOf[Seq[_]] should be(newSeq) 121 | argument.value.get.asInstanceOf[Seq[_]](3) should be(4) 122 | // try with setArgument 123 | argument.setArgument(seq.map(i => i.toString):_*) 124 | argument.value.get.asInstanceOf[Seq[_]].toList should be(seq.map(_.toString)) 125 | argument.value.get.asInstanceOf[Seq[_]](2) should be("3") 126 | } 127 | 128 | it should "store a Seq[Int]" in { 129 | val seq = Seq[Int](1, 2, 3) 130 | val newSeq = Seq[Int](1, 2, 3, 4) 131 | val argument = makeClpArgument(classOf[SeqIntClass], seq) 132 | argument.isFlag should be(false) 133 | argument.isCollection should be(true) 134 | argument.value.get should be(seq.asInstanceOf[Any]) 135 | argument.value.get.asInstanceOf[Seq[_]] should be(seq) 136 | argument.value.get.asInstanceOf[Seq[_]](2) should be(3) 137 | argument.typeDescription shouldBe "Int" 138 | // try with.value = 139 | argument.value = newSeq 140 | argument.value.get should be(newSeq.asInstanceOf[Any]) 141 | argument.value.get.asInstanceOf[Seq[_]] should be(newSeq) 142 | argument.value.get.asInstanceOf[Seq[_]](3) should be(4) 143 | // try with setArgument 144 | argument.setArgument(seq.map(i => i.toString):_*) 145 | argument.value.get.asInstanceOf[Seq[_]].toList should be(seq) // major difference #1 : this is an int 146 | argument.value.get.asInstanceOf[Seq[_]](2) should be(3) // major difference #2 : this is an int 147 | } 148 | 149 | it should "store a Option[PathToNowhere]" in { 150 | val aPath: Option[PathToNowhere] = Some(PathUtil.pathTo("a", "path")) 151 | val aNewPath = Option(PathUtil.pathTo("b", "path")) 152 | val argument = makeClpArgument(classOf[PathToNowhereOptionClass], aPath) 153 | argument.isFlag should be(false) 154 | argument.isCollection should be(false) 155 | argument.value.get should be(aPath.asInstanceOf[Any]) 156 | argument.value.get.asInstanceOf[Option[PathToNowhere]] should be(aPath) 157 | argument.hasValue shouldBe true 158 | argument.typeDescription shouldBe "PathToNowhere" 159 | // try with.value = 160 | argument.value = aNewPath 161 | argument.value.get shouldBe aNewPath.asInstanceOf[Any] 162 | argument.value.get.asInstanceOf[Option[PathToNowhere]] should be (aNewPath) 163 | argument.value.get.asInstanceOf[Option[PathToNowhere]].value should be (aNewPath.get) 164 | // try with setArgument 165 | argument.setArgument(aPath.get.toString) 166 | argument.value.get shouldBe aPath.asInstanceOf[Any] 167 | argument.value.get.asInstanceOf[Option[PathToNowhere]] should be (aPath) 168 | argument.value.get.asInstanceOf[Option[PathToNowhere]].value should be (aPath.get) 169 | } 170 | 171 | it should "store a Option[PathToNowhere] with a default value of None" in { 172 | val aPath: Option[Option[PathToNowhere]] = None 173 | val aNewPath = Option(PathUtil.pathTo("b", "path")) 174 | val argument = makeClpArgument(classOf[PathToNowhereOptionClass], aPath) 175 | argument.isFlag should be(false) 176 | argument.isCollection should be(false) 177 | argument.value.get should be(aPath.asInstanceOf[Any]) 178 | argument.value.get.asInstanceOf[Option[PathToNowhere]] should be(aPath) 179 | argument.hasValue shouldBe false 180 | argument.typeDescription shouldBe "PathToNowhere" 181 | // try with.value = 182 | argument.value = aNewPath 183 | argument.value.get shouldBe aNewPath.asInstanceOf[Any] 184 | argument.value.get.asInstanceOf[Option[PathToNowhere]] should be (aNewPath) 185 | argument.value.get.asInstanceOf[Option[PathToNowhere]].value should be (aNewPath.get) 186 | // try with setArgument 187 | // FIXME: need a way to give a string that creates "None" 188 | //argument.setArgument(aPath.get.toString) 189 | //argument.value.get shouldBe aPath.asInstanceOf[Any] 190 | //argument.value.get.asInstanceOf[Option[PathToNowhere]] should be (aPath) 191 | //argument.value.get.asInstanceOf[Option[PathToNowhere]].value should be (aPath.get) 192 | } 193 | 194 | it should "get the description correctly for type aliases" in { 195 | val parent = new PathToNowhereOptionClass(Some(PathUtil.pathTo("a", "path"))) 196 | val field = parent.getClass.getDeclaredFields.head 197 | val annotation = field.getAnnotation(classOf[arg]) 198 | val argument = makeClpArgument(classOf[PathToNowhereOptionClass], null) 199 | argument.typeDescription shouldBe "PathToNowhere" 200 | } 201 | 202 | it should "store an argument without an annotation" in { 203 | val argument = makeClpArgument(classOf[NoAnnotation], false) 204 | argument.isFlag should be(true) 205 | argument.value.get should be(false.asInstanceOf[Any]) 206 | argument.value.get.asInstanceOf[Boolean] should be(false) 207 | argument.typeDescription shouldBe "Boolean" 208 | argument.optional shouldBe true 209 | argument.hasValue shouldBe true 210 | // try with.value = 211 | argument.value = true 212 | argument.value.get should be(true.asInstanceOf[Any]) 213 | argument.value.get.asInstanceOf[Boolean] should be(true) 214 | // try with setArgument 215 | argument.setArgument("false") 216 | argument.value.get should be(false.asInstanceOf[Any]) 217 | argument.value.get.asInstanceOf[Boolean] should be(false) 218 | } 219 | 220 | it should "throw an IllegalStateException when creating an argument without an annotation with no default" in { 221 | an[IllegalStateException] should be thrownBy new ClpReflectiveBuilder(classOf[NoAnnotationNoDefault]).argumentLookup.iterator.next 222 | } 223 | } 224 | 225 | 226 | -------------------------------------------------------------------------------- /src/test/scala/com/fulcrumgenomics/sopt/parsing/OptionParserTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics.sopt.parsing 26 | 27 | import com.fulcrumgenomics.sopt.util.UnitSpec 28 | import org.scalatest.PrivateMethodTester 29 | 30 | import scala.util.Success 31 | 32 | class OptionParserTest extends UnitSpec with PrivateMethodTester { 33 | 34 | "OptionParser.parse" should "fail with an OptionNameException for illegal arguments" in { 35 | val parser = new OptionParser // NB: no valid options! 36 | parser.parse("-ABC").failed.get.getClass shouldBe classOf[IllegalOptionNameException] 37 | parser.parse("-A-B-C").failed.get.getClass shouldBe classOf[IllegalOptionNameException] 38 | parser.parse("-=").failed.get.getClass shouldBe classOf[IllegalOptionNameException] 39 | parser.parse("-A=").failed.get.getClass shouldBe classOf[IllegalOptionNameException] 40 | parser.parse("-=A").failed.get.getClass shouldBe classOf[IllegalOptionNameException] 41 | parser.parse("-ABC=ABC").failed.get.getClass shouldBe classOf[IllegalOptionNameException] 42 | parser.parse("-A-B-C=ABC").failed.get.getClass shouldBe classOf[IllegalOptionNameException] 43 | parser.parse("--A-B-C=ABC").failed.get.getClass shouldBe classOf[IllegalOptionNameException] 44 | } 45 | 46 | it should "parse empty arguments" in { 47 | val parser = new OptionParser() 48 | parser.parse() shouldBe Success(parser) 49 | } 50 | 51 | it should "fail with an OptionNameException for an empty argument" in { 52 | val parser = new OptionParser() 53 | parser.parse("").failed.get.getClass shouldBe classOf[OptionNameException] 54 | } 55 | 56 | it should "fail with an IllegalOptionNameException for illegal options" in { 57 | val parser = new OptionParser 58 | parser.parse("-f", "foobar").failed.get.getClass shouldBe classOf[IllegalOptionNameException] 59 | } 60 | 61 | it should "parse a flag field" in { 62 | // short names 63 | new OptionParser().acceptFlag("f").get.parse("-f").get.foreach { 64 | case (optionName, optionValues) => 65 | optionName shouldBe "f" 66 | optionValues shouldBe List("true") 67 | } 68 | new OptionParser().acceptFlag("f").get.parse("-f", "true").get.foreach { 69 | case (optionName, optionValues) => 70 | optionName shouldBe "f" 71 | optionValues shouldBe List("true") 72 | } 73 | new OptionParser().acceptFlag("f").get.parse("-f", "false").get.foreach { 74 | case (optionName, optionValues) => 75 | optionName shouldBe "f" 76 | optionValues shouldBe List("false") 77 | } 78 | new OptionParser().acceptFlag("f").get.parse("-ffalse").get.foreach { 79 | case (optionName, optionValues) => 80 | optionName shouldBe "f" 81 | optionValues shouldBe List("false") 82 | } 83 | new OptionParser().acceptFlag("f").get.parse("-f=false").get.foreach { 84 | case (optionName, optionValues) => 85 | optionName shouldBe "f" 86 | optionValues shouldBe List("false") 87 | } 88 | // long names 89 | new OptionParser().acceptFlag("flag").get.parse("--flag").get.foreach { 90 | case (optionName, optionValues) => 91 | optionName shouldBe "flag" 92 | optionValues shouldBe List("true") 93 | } 94 | new OptionParser().acceptFlag("flag").get.parse("--flag", "true").get.foreach { 95 | case (optionName, optionValues) => 96 | optionName shouldBe "flag" 97 | optionValues shouldBe List("true") 98 | } 99 | new OptionParser().acceptFlag("flag").get.parse("--flag", "false").get.foreach { 100 | case (optionName, optionValues) => 101 | optionName shouldBe "flag" 102 | optionValues shouldBe List("false") 103 | } 104 | new OptionParser().acceptFlag("flag").get.parse("--flag=false").get.foreach { 105 | case (optionName, optionValues) => 106 | optionName shouldBe "flag" 107 | optionValues shouldBe List("false") 108 | } 109 | new OptionParser().acceptFlag("this-is-a-flag").get.parse("--this-is-a-flag=false").get.foreach { 110 | case (optionName, optionValues) => 111 | optionName shouldBe "this-is-a-flag" 112 | optionValues shouldBe List("false") 113 | } 114 | } 115 | 116 | it should "parse multiple long option names with their values separated by an equals sign, when there value is of length one" in { 117 | val parser = new OptionParser().acceptSingleValue("single-a").get.acceptSingleValue("single-b").get 118 | parser.parse("--single-b=3", "--single-a=1") shouldBe Symbol("success") 119 | parser should have size 2 120 | parser.foreach { case (observedArgument, optionValues) => 121 | val found = observedArgument match { 122 | case "single-a" => 123 | optionValues.toList should contain("1") 124 | true 125 | case "single-b" => 126 | optionValues.toList should contain("3") 127 | true 128 | case _ => false 129 | } 130 | found shouldBe true 131 | } 132 | } 133 | 134 | it should "fail with an IllegalFlagValueException for an illegal value for a flag field" in { 135 | val parser = new OptionParser().acceptFlag("f").get 136 | parser.parse("-f", "foobar").failed.get.getClass shouldBe classOf[IllegalFlagValueException] 137 | } 138 | 139 | "OptionParser" should "should parse a basic command line" in { 140 | val parser = new OptionParser().acceptFlag("f").get.acceptSingleValue("s").get.acceptMultipleValues("m").get 141 | parser.parse("-f", "false", "-s", "single-value", "-m", "multi-value-1", "multi-value-2") shouldBe Symbol("success") 142 | parser should have size 3 143 | parser.foreach { case (observedArgument, optionValues) => 144 | val found = observedArgument match { 145 | case "f" => 146 | optionValues.toList should contain("false") 147 | true 148 | case "s" => 149 | optionValues.toList should contain("single-value") 150 | true 151 | case "m" => 152 | optionValues.toList should contain("multi-value-1") 153 | optionValues.toList should contain("multi-value-2") 154 | true 155 | case _ => false 156 | } 157 | found shouldBe true 158 | } 159 | } 160 | 161 | it should "should parse a very basic command line" in { 162 | List(true, false).foreach { strict => 163 | val parser = new OptionParser().acceptFlag("f").get.acceptSingleValue("s").get.acceptMultipleValues("m").get 164 | parser.parse("-f", "-s", "single-value", "-m", "multi-value-1", "multi-value-2") shouldBe Symbol("success") 165 | parser should have size 3 166 | parser.foreach { case (observedArgument, optionValues) => 167 | val found = observedArgument match { 168 | case "f" => 169 | optionValues.toList should contain("true") 170 | true 171 | case "s" => 172 | optionValues.toList should contain("single-value") 173 | true 174 | case "m" => 175 | optionValues.toList should contain("multi-value-1") 176 | optionValues.toList should contain("multi-value-2") 177 | true 178 | case _ => false 179 | } 180 | found shouldBe true 181 | } 182 | } 183 | } 184 | 185 | // NB: not re-testing many of the failure conditions already tested in OptionLookup and ArgTokenizer 186 | it should "parse a command line with a flag argument" in { 187 | List(true, false).foreach { strict => 188 | List(List[String]("-f"), List[String]("-f", "false"), List[String]("-f=false")).foreach { args => 189 | val parser = new OptionParser().acceptFlag("f").get.acceptSingleValue("s").get.acceptMultipleValues("m").get 190 | parser.parse(args: _*) shouldBe Symbol("success") 191 | parser should have size 1 192 | parser.foreach { case (observedArgument, optionValues) => 193 | val found = observedArgument match { 194 | case "f" => 195 | if (0 == args.head.compareTo("-f") && args.length == 1) { 196 | optionValues.toList should contain("true") 197 | } 198 | else { 199 | optionValues.toList should contain("false") 200 | } 201 | true 202 | case _ => false 203 | } 204 | found shouldBe true 205 | } 206 | parser.remaining shouldBe Symbol("empty") 207 | } 208 | } 209 | } 210 | 211 | "OptionParser.remaining" should "return all arguments that were not parsed correctly" in { 212 | { 213 | val args = List("val0") 214 | val parser = new OptionParser().acceptSingleValue("t").get.acceptSingleValue("s").get.acceptSingleValue("u").get 215 | parser.parse(args: _*) shouldBe Symbol("failure") 216 | parser.remaining should have size 1 217 | parser.remaining should be(List("val0")) 218 | } 219 | { 220 | val args = List("-s", "val1", "val2") 221 | val parser = new OptionParser().acceptSingleValue("s").get 222 | parser.parse(args: _*) shouldBe Symbol("failure") 223 | parser.remaining should have size 3 224 | parser.remaining should be(List("-s", "val1", "val2")) 225 | } 226 | { 227 | val args = List("-t", "val0", "-s", "val1", "val2") 228 | val parser = new OptionParser().acceptSingleValue("t").get.acceptSingleValue("s").get 229 | parser.parse(args: _*) shouldBe Symbol("failure") 230 | parser.remaining should have size 3 231 | parser.remaining should be(List("-s", "val1", "val2")) 232 | } 233 | { 234 | val args = List("-t", "val0", "-s", "val1", "val2", "-u", "val3") 235 | val parser = new OptionParser().acceptSingleValue("t").get.acceptSingleValue("s").get.acceptSingleValue("u").get 236 | parser.parse(args: _*) shouldBe Symbol("failure") 237 | parser.remaining should have size 5 238 | parser.remaining should be(List("-s", "val1", "val2", "-u", "val3")) 239 | } 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/main/scala/com/fulcrumgenomics/sopt/parsing/OptionLookup.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics.sopt.parsing 26 | 27 | import com.fulcrumgenomics.sopt.util.ParsingUtil 28 | 29 | import scala.collection.mutable 30 | import scala.collection.mutable.ListBuffer 31 | import scala.util.{Failure, Success, Try} 32 | 33 | // NB: no manipulation of the values or names should be performed prior to calling addOptionValues... 34 | 35 | /** Helper methods for looking up options, including their names, types, and values */ 36 | protected object OptionLookup { 37 | /** The type of argument we should parse */ 38 | object OptionType extends Enumeration { 39 | type OptionType = Value 40 | val Flag, SingleValue, MultiValue = Value 41 | } 42 | 43 | import OptionType._ 44 | 45 | /** Stores the values from the command line for the given known argument along with its various names (aliases). */ 46 | class OptionAndValues(val optionType: OptionType, val optionNames: Seq[String]) { 47 | 48 | private val values: ListBuffer[String] = new ListBuffer[String]() 49 | 50 | private def ensureSingleValue(optionName: String): Try[this.type] = { 51 | if (1 < values.size) Failure(OptionSpecifiedMultipleTimesException(s"'$optionName' specified more than once.")) 52 | else Success(this) 53 | } 54 | 55 | private def addFlag(optionName: String, addedValues: String*): Try[this.type] = { 56 | addedValues.toList match { 57 | case Nil => 58 | values += "true" 59 | ensureSingleValue(optionName) 60 | case xs :: Nil => 61 | val converted = addedValues.map(convertFlagValue) 62 | converted.find(_.isFailure) match { 63 | case Some(Failure(ex)) => Failure(ex) 64 | case _ => 65 | values ++= converted.map(_.get) 66 | ensureSingleValue(optionName) 67 | } 68 | case _ => Failure(TooManyValuesException(s"Trying to add more than one value for the flag option: '$optionName'")) 69 | } 70 | } 71 | 72 | private def addSingleValue(optionName: String, addedValues: String*): Try[this.type] = { 73 | addedValues.toList match { 74 | case Nil => Failure(TooFewValuesException(s"No values given for the single-value option: '$optionName'")) 75 | case xs :: Nil => 76 | values ++= addedValues 77 | if (1 < values.size) Failure(OptionSpecifiedMultipleTimesException(s"'$optionName' specified more than once.")) 78 | else Success(this) 79 | case _ => Failure(TooManyValuesException(s"Trying to add more than one value for the single-value option: '$optionName'")) 80 | } 81 | } 82 | 83 | def add(optionName: String, addedValues: String*): Try[this.type] = { 84 | if (!optionNames.exists( name => name.startsWith(optionName))) { 85 | Failure(IllegalOptionNameException(s"Option name '$optionName' was not found in the list of option names (or as a prefix)")) 86 | } 87 | else { 88 | // 1. Check that we have the correct # of addedValues based on the option type 89 | // 2. Add the values 90 | // 3. Check that we have the correct # of values based on the option type 91 | this.optionType match { 92 | case Flag => 93 | addFlag(optionName = optionName, addedValues: _*) 94 | case SingleValue => 95 | addSingleValue(optionName = optionName, addedValues: _*) 96 | case MultiValue => 97 | if (addedValues.isEmpty) Failure(TooFewValuesException(s"No values given for the multi-value option: '$optionName'")) 98 | else { 99 | values ++= addedValues 100 | Success(this) 101 | } 102 | } 103 | } 104 | } 105 | 106 | def isEmpty: Boolean = values.isEmpty 107 | 108 | def nonEmpty: Boolean = values.nonEmpty 109 | 110 | def toList: List[String] = values.toList 111 | } 112 | 113 | /** Tries to convert the string `value` to a true or false string value. The value must be one of 114 | * T|True|F|False|Y|Yes|N|No ignoring case */ 115 | def convertFlagValue(value: String): Try[String] = { 116 | value.toLowerCase match { 117 | case v if Set[String]("true", "t", "yes", "y").contains(v) => Success("true") 118 | case v if Set[String]("false", "f", "no", "n").contains(v) => Success("false") 119 | case v => Failure(IllegalFlagValueException(s"$value does not match one of T|True|F|False|Yes|Y|No|N")) 120 | } 121 | } 122 | } 123 | 124 | /** Stores information about the option specifications and their associated values */ 125 | trait OptionLookup { 126 | 127 | import OptionLookup.OptionType._ 128 | import OptionLookup._ 129 | 130 | /** Map from option names and aliases to the structure that holds their values */ 131 | protected[sopt] val optionMap: mutable.Map[String, OptionAndValues] = new mutable.HashMap[String, OptionAndValues] 132 | 133 | /** List of all option names. */ 134 | protected[sopt] def optionNames = optionMap.keys 135 | 136 | /** Add a flag argument with the given name(s) */ 137 | def acceptFlag(optionName: String*): Try[this.type] = { 138 | accept(Flag, optionName: _*) 139 | } 140 | 141 | /** Add a single value argument with the given name(s) */ 142 | def acceptSingleValue(optionName: String*): Try[this.type] = { 143 | accept(SingleValue, optionName: _*) 144 | } 145 | 146 | /** Add a multi value argument with the given name(s) */ 147 | def acceptMultipleValues(optionName: String*): Try[this.type] = { 148 | accept(MultiValue, optionName: _*) 149 | } 150 | 151 | /** Adds the given argument type with argument name(s) */ 152 | private def accept(optionType: OptionType.Value, optionName: String*): Try[this.type] = { 153 | val optionValues = new OptionAndValues(optionType, optionName.toSeq) 154 | optionName.view.map { name => 155 | optionMap.put(name, optionValues) match { 156 | case Some(_) => Failure(DuplicateOptionNameException(s"option name '$name' specified more than once")) 157 | case _ => Success(this) 158 | } 159 | }.find(_.isFailure) match { 160 | case Some(Failure(ex)) => Failure(ex) 161 | case _ => Success(this) 162 | } 163 | } 164 | 165 | /** Gets all the option names with the given string as a prefix */ 166 | private def optionNamesWithPrefix(prefix: String): Iterable[String] = { 167 | this.optionNames.filter { name => 168 | name.startsWith(prefix) 169 | } 170 | } 171 | 172 | /** Gets all the option and values with the given string as a prefix of name */ 173 | private def optionAndValuesWithPrefix(prefix: String): Iterable[OptionAndValues] = { 174 | this.optionMap 175 | .filter { case (name, optionAndValues) => name.startsWith(prefix) } 176 | .map { case (name, optionAndValues) => optionAndValues } 177 | } 178 | 179 | /** Gets the single option with this name. If no option with the name is found, returns all options that have this 180 | * name as a prefix. 181 | */ 182 | private[sopt] def findExactOrPrefix(optionName: String): List[OptionAndValues] = { 183 | // first see if the name is just in the map 184 | optionMap.get(optionName) match { 185 | case Some(v) => List(v) 186 | case None => // next, check abbreviations 187 | optionAndValuesWithPrefix(optionName).toList 188 | } 189 | } 190 | 191 | /** True if there is one and only one option with this name or a prefix, false otherwise. */ 192 | def hasOptionName(optionName: String): Boolean = this.findExactOrPrefix(optionName).size == 1 193 | 194 | /** True if the option name or its abbreviation will return at least one value if `getOptionValues` were called, false otherwise. */ 195 | def hasOptionValues(optionName: String): Boolean = { 196 | if (hasOptionName(optionName)) { 197 | optionValues(optionName) match { 198 | case Success(list) => list.nonEmpty 199 | case Failure(_) => false 200 | } 201 | } 202 | else { 203 | false 204 | } 205 | } 206 | 207 | /** Gets the single value for the option with the given name or prefix. A success requires one and only one value. */ 208 | def singleValue(optionName: String): Try[String] = { 209 | optionValues(optionName) match { 210 | case Success(list) if list.size == 1 => Success(list.head) 211 | case Success(list) if list.isEmpty => Failure(IllegalOptionNameException(s"No values found for option '$optionName'")) 212 | case Success(list) if list.size > 1 => Failure(IllegalOptionNameException(s"Multiple values found for option '$optionName': " + list.mkString(", "))) 213 | case Failure(throwable) => Failure(throwable) 214 | } 215 | } 216 | 217 | /** Gets the values for the option with the given name or prefix. A success requires at least one value. */ 218 | def optionValues(optionName: String): Try[List[String]] = { 219 | this.findExactOrPrefix(optionName) match { 220 | case Nil => 221 | Failure(IllegalOptionNameException(printUnknown(optionName))) 222 | case option :: Nil => Success(option.toList) 223 | case _ => 224 | Failure(DuplicateOptionNameException(printMultipleValuesFound(optionName))) 225 | } 226 | } 227 | 228 | /** Adds value(s) to the given option and returns all values for the given option */ 229 | protected[sopt] def addOptionValues(optionName: String, values: String*): Try[Iterable[String]] = { 230 | this.findExactOrPrefix(optionName) match { 231 | case Nil => Failure(IllegalOptionNameException(printUnknown(optionName))) 232 | case option :: Nil => 233 | option.add(optionName, values:_*) match { 234 | case Success(opt) => Success(opt.toList) 235 | case Failure(failure) => Failure(failure) 236 | } 237 | case _ => 238 | Failure(DuplicateOptionNameException(printMultipleValuesFound(optionName))) 239 | } 240 | } 241 | 242 | private[sopt] def printUnknown(optionName: String): String = { 243 | s"No option found with name '$optionName'.${ParsingUtil.printUnknown(optionName, optionNames)}" 244 | } 245 | 246 | private [sopt] def printMultipleValuesFound(optionName: String): String = { 247 | s"Multiple options found for name '$optionName': " + optionNamesWithPrefix(optionName).mkString(", ") 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/fulcrumgenomics/sopt/workflows/unit%20tests/badge.svg)](https://github.com/fulcrumgenomics/sopt/actions?query=workflow%3A%22unit+tests%22) 2 | [![Coverage Status](https://codecov.io/github/fulcrumgenomics/sopt/coverage.svg?branch=main)](https://codecov.io/github/fulcrumgenomics/sopt?branch=main) 3 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.fulcrumgenomics/sopt_2.12/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.fulcrumgenomics/sopt_2.12) 4 | [![Javadocs](http://javadoc.io/badge/com.fulcrumgenomics/sopt_2.12.svg)](http://javadoc.io/doc/com.fulcrumgenomics/sopt_2.12) 5 | [![License](http://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/fulcrumgenomics/sopt/blob/main/LICENSE) 6 | [![Language](http://img.shields.io/badge/language-scala-brightgreen.svg)](http://www.scala-lang.org/) 7 | 8 | # sopt - Scala Option Parsing Library 9 | 10 | _sopt_ is a scala library for command line option parsing with minimal dependencies. It is designed for toolkits that have multiple "commands" such as [dagr](https://github.com/fulcrumgenomics/dagr) and [fgbio](https://github.com/fulcrumgenomics/fgbio). The latest API documentation can be found [here](http://javadoc.io/doc/com.fulcrumgenomics/sopt_2.12). 11 | 12 |

13 | Fulcrum Genomics 14 |

15 | 16 | [Visit us at Fulcrum Genomics](https://www.fulcrumgenomics.com) to learn more about how we can power your Bioinformatics with sopt and beyond. 17 | 18 | 19 | 20 | 21 | _sopt_ has the following high level features: 22 | 23 | - Support for GNU/posix style argument names and conventions 24 | - Camel case names in scala are auto-translated to GNU style options (e.g. `inputFile -> input-file`) 25 | - Argument configuration via annotations on scala classes 26 | - Reflectively builds typed strongly typed argument values for most simple types: 27 | - Primitive types like `Int` and `Double` 28 | - Any type that has a constructor from `String` or a companion `apply(String)` 29 | - Option types, e.g. `Option[Int]` or `Option[String]`, etc. 30 | - Collection types, e.g. `Seq[Int]` or `Set[String`], etc. 31 | - Typedefs used for argument types and will be used in usage & documentation 32 | - Argument file support similar to Python's Argparse 33 | - Command usage and argument documentation in [GitHub flavored MarkDown](https://guides.github.com/features/mastering-markdown/), formatted appropriately for the terminal 34 | - Terminal output using ANSI escape sequences to color and highlight usage 35 | - APIs to retrieve command and argument metadata, e.g. to create offline documentation 36 | 37 | ## Getting Started 38 | To use sopt you will need to add it to your build. For sbt this looks like: 39 | 40 | ```scala 41 | libraryDependencies += "com.fulcrumgenomics" %% "sopt" % "1.1.0" 42 | ``` 43 | 44 | You'll then need the following: 45 | 46 | 1. A `trait` which all your commands or tools will extend 47 | 1. One or more command classes 48 | 1. An `object` with a main method that invokes `sopt` to parse the command line 49 | 50 | ### A Short Example 51 | 52 | The following is a minimal example of a use of sopt. 53 | 54 | ```scala 55 | package example 56 | 57 | import com.fulcrumgenomics.sopt._ 58 | import com.fulcrumgenomics.sopt.Sopt._ 59 | import example.Types._ 60 | 61 | /** All command classes exposed on the command-line will extend or mix-in this trait. */ 62 | trait Tool { def execute(): Unit } 63 | 64 | object Types { 65 | type Name = String 66 | } 67 | 68 | /** An example command. */ 69 | @clp(description= 70 | """ 71 | |An example program that greets people. The `greeting` argument is optional 72 | |since it has a default. 73 | """) 74 | class Greet( 75 | @arg(flag='g', doc="The greeting.") val greeting: String = "Hello", 76 | @arg(flag='n', doc="Someone's name.") val name: Name 77 | ) extends Tool { 78 | override def execute(): Unit = System.out.println(s"$greeting $name!") 79 | } 80 | 81 | /** The main class that invokes sopt. */ 82 | object Main { 83 | def main(args: Array[String]): Unit = { 84 | val commands = Sopt.find[Tool](packages=Seq("example")) 85 | Sopt.parseCommand[Tool](name="example-kit", args=args, commands=commands) match { 86 | case Failure(usage) => 87 | System.err.print(usage()) 88 | System.exit(1) 89 | case CommandSuccess(tool) => 90 | tool.execute() 91 | System.exit(0) 92 | } 93 | } 94 | } 95 | ``` 96 | 97 | The `Tool` trait is not required, but it is generally useful to have a trait which is implemented by all commands, so that not only can they have a common method (ex. `execute()`) that can be invoked to _do something_, the available commands can be found using the `Tool` trait and `Sopt.find`. Alternatively you could invoke `Sopt.parseCommand[AnyRef]` directly! To do so, you would need to manually construct the list of commands instead of searching for subclasses with `Sopt.find[AnyRef](packages)`. 98 | 99 | In this example, the `Main` class is implemented only once and can act as a dispatcher to any number of commands that implement `Tool`. It also acts as a central point to add consistent behaviour amongst tools, for example printing out the date and time at the start and end of execution. 100 | 101 | Running the `Main` class from the command line yields the following output: 102 | 103 | ``` 104 | USAGE: example-kit [command] [arguments] 105 | Version: 1.0 106 | ----------------------------------------------------------------------------- 107 | 108 | Available Sub-Commands: 109 | ----------------------------------------------------------------------------- 110 | Clps: Various command line programs. 111 | GreetingCommand An example program that greets people. 112 | ----------------------------------------------------------------------------- 113 | 114 | No sub-command given. 115 | ``` 116 | 117 | Running the `Main` class and specifying the `Greet` command without any arguments produces the following output: 118 | 119 | ``` 120 | USAGE: Greet [arguments] 121 | Version: 0.2.0-SNAPSHOT 122 | ------------------------------------------------------------------------------------------------ 123 | An example program that greets people. The greeting argument is optional since it has a default. 124 | 125 | Greet Required Arguments: 126 | ------------------------------------------------------------------------------------------------ 127 | -n String, --name=Name Someone's name. 128 | 129 | Greet Optional Arguments: 130 | ------------------------------------------------------------------------------------------------ 131 | -h [true|false], --help[=true|false] 132 | Display the help message. Default: false. 133 | -g String, --greeting=String The greeting. Default: Hello. 134 | --version[=true|false] Display the version number for this tool. Default: false. 135 | 136 | Error: Argument 'name' is required. 137 | ``` 138 | 139 | ## Documentation 140 | 141 | API documentation for all versions can be viewed on [javadoc.io](http://www.javadoc.io/doc/com.fulcrumgenomics/sopt_2.12/0.2.0). 142 | 143 | ## Argument Naming & Formatting 144 | 145 | Each argument may have a long name and optionally a short name. 146 | 147 | ### Short Names 148 | 149 | Short names are single-character names. Arguments with short names may be formatted on the command line as follows: 150 | 151 | - `-` 152 | - `-=` 153 | - `- ` 154 | 155 | ### Long names 156 | 157 | Long names may be of any length and are generally specified as one or more lower-case words separated with hyphens (e.g. `--input-files`). Long names may be used on the command line with the following formats: 158 | 159 | - `--=` 160 | - `-- ` 161 | 162 | Name must be `[A-Za-z0-9?][-A-Za-Z0-9?]*` 163 | 164 | The following is not supported: 165 | 166 | - `--` 167 | 168 | due to the prefix support described below. 169 | 170 | ### Prefixes 171 | 172 | Prefixes of option names are supported, as long as the a prefix is unambiguous, and either a ` `(whitespace) or `=` delimiter is used. 173 | 174 | In a command with no other options beginning with `foo` the following are equivalent: 175 | 176 | - `--foobar ` 177 | - `--fooba ` 178 | - `--foob ` 179 | - `--foo ` 180 | 181 | But if we have another option, e.g. `--football `, then using `--foo ` will cause a `DuplicateOptionNameException` to be thrown. 182 | 183 | ## Argument Types 184 | 185 | ### Flags 186 | 187 | Flags are arguments with a boolean (true or false) value. The value may be ommitted, in which case it is interpreted as if `true` were provided. The following are all equivalent: 188 | 189 | - `-x` 190 | - `-xtrue` 191 | - `-x=true` 192 | - `-xT` 193 | - `-x=T` 194 | 195 | `Yes` and `Y` are also interpreted as `true`; `No` and `N` are interpreted as `false`. Both are case insensitive. 196 | 197 | ### Single-value arguments 198 | 199 | Single value arguments may only be specified once on the command line. If the argument has a default value, the value from the command line will override it. Single-value arguments may be specified using long or short names, in the following ways: 200 | 201 | - `-x ` 202 | - `-x` 203 | - `-x=` 204 | - `--extra ` 205 | - `--extra=` 206 | 207 | ### Multi-value arguments 208 | 209 | Arguments that accept multiple values may be specified up to the maximum number of times specified by the tool. If an argument has a default value, the value(s) specified on the command line replace the default value (i.e. they do not add to it). 210 | 211 | Multiple values can specified via multiple name/value pairs, e.g.: 212 | 213 | - `--input=foo.txt --input=bar.txt --input=whee.txt` 214 | - `--input foo.txt --input bar.txt --input whee.txt` 215 | 216 | or with multiple values following a single argument name: 217 | 218 | - `--input foo.txt bar.txt whee.txt` 219 | - `--input *.txt` 220 | 221 | ### Optional arguments 222 | 223 | Arguments are optional on the command line if any of the following are true: 224 | 225 | - The argument is a primitive type and has a default value 226 | - The argument's type is `Option[_]` 227 | - The argument's type is a collection type (e.g. `Seq`) and `minElements` is set to 0 228 | 229 | `Option` and collection arguments that are optional, but have default values may be cleared by specifying the special argument value `:none:`. 230 | 231 | ### Argument files 232 | 233 | Argument files are modeled after the implementation in [Python's Argparse library](https://docs.python.org/3/library/argparse.html). In short: 234 | 235 | - Any argument prefixed with `@` is expected to be an argument file (e.g. `-foo=bar @whee.txt` species one parameter on the command line, and then a file called `whee.txt`) 236 | - The arguments present in an argument file are substituted into the command line at the point where the argument file is specified 237 | - Each line in the argument file is treated as a single token or argument 238 | 239 | The latter is especially useful when passing many arguments that include spaces or special characters. For example the following file: 240 | 241 | ``` 242 | --funny-strings 243 | Hello World! 244 | I'm a shooting *star* 245 | ``` 246 | 247 | is equivalent to `--funny-strings 'Hello World!' 'I\'m a shooting *star*'`. 248 | -------------------------------------------------------------------------------- /src/main/scala/com/fulcrumgenomics/sopt/util/MarkDownProcessor.scala: -------------------------------------------------------------------------------- 1 | package com.fulcrumgenomics.sopt.util 2 | 3 | /* 4 | * The MIT License 5 | * 6 | * Copyright (c) 2017 Fulcrum Genomics LLC 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy 9 | * of this software and associated documentation files (the "Software"), to deal 10 | * in the Software without restriction, including without limitation the rights 11 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | * copies of the Software, and to permit persons to whom the Software is 13 | * furnished to do so, subject to the following conditions: 14 | * 15 | * The above copyright notice and this permission notice shall be included in 16 | * all copies or substantial portions of the Software. 17 | * 18 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | * THE SOFTWARE. 25 | */ 26 | 27 | import com.vladsch.flexmark.ast._ 28 | import com.vladsch.flexmark.html.HtmlRenderer 29 | import com.vladsch.flexmark.parser.{Parser, ParserEmulationProfile} 30 | import com.vladsch.flexmark.util.options.MutableDataSet 31 | 32 | import scala.collection.mutable.ListBuffer 33 | import com.fulcrumgenomics.commons.CommonsDef._ 34 | 35 | /** Represents a chunk of text from MarkDown that is in the process of being laid out in plain text. */ 36 | case class Chunk(text: String, wrappable: Boolean, indent: Int, var gutter: Int = 0, var prefix: String = "") { 37 | def isEmpty: Boolean = text.isEmpty 38 | def nonEmpty: Boolean = !isEmpty 39 | def withGutter(gutter: Int) : Chunk = { this.gutter = gutter; this } 40 | def withPrefix(prefix: String) : Chunk = { this.prefix = prefix; this } 41 | override def toString: String = text 42 | } 43 | 44 | object Chunk { 45 | def wrappable(indent:Int, text: String) = new Chunk(text=text, wrappable=true, indent=indent) 46 | def wrappable(indent:Int, text: String*) = text.map(t => new Chunk(text=t, wrappable=true, indent=indent)) 47 | def unwrappable(indent: Int, text: String) = new Chunk(text=text, wrappable=false, indent=indent) 48 | def unwrappable(indent: Int, text: String*) = text.map(t => new Chunk(text=t, wrappable=false, indent=indent)) 49 | def empty = new Chunk(text="", wrappable=false, indent=0) 50 | } 51 | 52 | object MarkDownProcessor { 53 | 54 | /** Matches any leading consecutive ANSI escape codes. */ 55 | private val leadingAnsiColorRegex = raw"^(\u001B\[\d+m)*".r 56 | 57 | /** Matches any trailing consecutive ANSI escape codes. */ 58 | private val trailingAnsiColorRegex = raw"(\u001B\[\d+m)*$$".r 59 | 60 | /** Removes all leading and trailing whitespace, ignoring leading or trailing ANSI escape codes. If no ANSI escape 61 | * codes are present, this is equivalent to the `trim` method in [[String]]. 62 | * 63 | * @example 64 | * `"ABC"` -> `"ABC"` (no trimming) 65 | * `"ABC"` -> `"ABC"` (no trimming) 66 | * `"ABC"` -> `"ABC"` (no trimming) 67 | * `"ABC"` -> `"ABC"` (no trimming) 68 | * `" ABC"` -> `"ABC"` (trimming, no ansi escape codes) 69 | * `"ABC "` -> `"ABC"` (trimming, no ansi escape codes) 70 | * `" ABC "` -> `"ABC"` (trimming, no ansi escape codes) 71 | * `" ABC"` -> `"ABC"` (trimming, ansi escape codes preserved) 72 | * `"ABC "` -> `"ABC"` (trimming, ansi escape codes preserved) 73 | * `" ABC "` -> `"ABC"` (trimming, ansi escape codes preserved) 74 | * */ 75 | private[util] def trim(string: String): String = if (string.isEmpty) string else { 76 | val prefix = this.leadingAnsiColorRegex.findFirstIn(string).getOrElse("") 77 | val suffix = this.trailingAnsiColorRegex.findFirstIn(string).getOrElse("") 78 | val middle = string.substring(prefix.length, string.length - suffix.length) 79 | prefix + middle.trim + suffix 80 | } 81 | } 82 | 83 | /** 84 | * Class for working with MarkDown documents and converting them to HTML or to line-wrapped 85 | * plain text. 86 | */ 87 | class MarkDownProcessor(lineLength: Int = 80, indentSize: Int = 2) { 88 | // Re-usable chunks to avoid creating them left and right 89 | private val NoChunk = Seq.empty[Chunk] 90 | private val EmptyChunk = Seq(Chunk.empty) 91 | private val SpaceChunk = Seq(Chunk.unwrappable(0, " ")) 92 | 93 | // A markdown parser 94 | private val (parser, htmlRenderer) = { 95 | val options = new MutableDataSet() 96 | options.setFrom(ParserEmulationProfile.COMMONMARK) 97 | (Parser.builder(options).build, HtmlRenderer.builder(options).build) 98 | } 99 | 100 | /** Parses a MarkDown document into an AST. */ 101 | def parse(markdown: String): Document = { 102 | parser.parse(markdown).asInstanceOf[Document] 103 | } 104 | 105 | /** Converts a MarkDown document to text. */ 106 | def toText(document: Node): Seq[String] = { 107 | val lines = process(document, indent=0) 108 | lines.flatMap(indentAndWrap) 109 | } 110 | 111 | /** Converts a MarkDown document to HTML. */ 112 | def toHtml(document: Node): String = this.htmlRenderer.render(document).trim 113 | 114 | 115 | 116 | /** 117 | * Recursive method that does the real work of converting a MarkDown document to text. Navigates 118 | * the AST recursively collapsing things down into text while maintaining the necessary indents, 119 | * blank lines after block elements (paragraphs, lists, code blocks, etc.). 120 | * 121 | * Handling of line-spacing before/after block elements is slightly tricky. It is implemented so that 122 | * block elements (paragraphs, code blocks, lists) generate a sequence of Chunks that end in a blank line. 123 | * It is then up to the enclosing element to remove the trailing blank line if it is no longer needed. 124 | * For example a document consisting of 5 paragraphs will generate blank lines after each paragraph, 125 | * and then remove the last one at the end of the document; lists generate a blank line after the 126 | * end of the list - but this is removed by the enclosing list if it is a sub-list and not a top-level list. 127 | 128 | * @param node the current node being converted 129 | * @param indent the number of indents at the current position in the document 130 | * @param listPosition if inside an ordered list, what is the current list element number 131 | * @return a sequence of zero or more Chunks representing the content from this point down 132 | */ 133 | private def process(node: Node, indent:Int, listPosition: Int = 0): Seq[Chunk] = node match { 134 | case document: Document => 135 | withoutTrailingEmptyLine(document.getChildren.toSeq.flatMap(node => process(node, indent))) 136 | case heading : Heading => 137 | val text = heading.getText.toString.trim 138 | val underline = if (heading.getLevel > 1) "-" else "=" 139 | Chunk.unwrappable(indent, text, underline * text.length, "") 140 | case para : Paragraph => 141 | val lines = para.getChildren.flatMap(c => process(c, indent)) 142 | val text = lines.mkString 143 | Chunk.wrappable(indent, text, "") 144 | case br: SoftLineBreak => 145 | if (br.getNext != null && br.getNext.isInstanceOf[SoftLineBreak]) Seq.empty else SpaceChunk 146 | case list: BulletList => 147 | list.getChildren.flatMap(c => process(c, indent+1)).toSeq ++ Seq(Chunk.empty) 148 | case item: BulletListItem => 149 | val children = item.getChildren.toSeq 150 | val text = process(children.head, indent) 151 | val bullet = Chunk.wrappable(indent, "* " + text.mkString).withGutter(2) 152 | val subs = children.tail.flatMap(c => withoutTrailingEmptyLine(process(c, indent + 1))) 153 | bullet +: subs 154 | case list: OrderedList => 155 | list.getChildren.zipWithIndex.flatMap { case (c, i) => process(c, indent+1, i+1) }.toSeq ++ Seq(Chunk.empty) 156 | case item: OrderedListItem => 157 | val children = item.getChildren.toSeq 158 | val text = process(children.head, indent) 159 | val prefix = s"$listPosition. " 160 | val bullet = Chunk.wrappable(indent, prefix + text.mkString).withGutter(prefix.length) 161 | val subs = children.tail.flatMap(c => withoutTrailingEmptyLine(process(c, indent))) 162 | bullet +: subs 163 | case code : FencedCodeBlock => 164 | code.getContentChars.toString.linesIterator.toSeq.flatMap(line => Chunk.unwrappable(indent+1, line)) ++ Seq(Chunk.empty) 165 | case quote: BlockQuote => 166 | val lines = quote.getChildren.flatMap(c => process(c, indent)) 167 | val text = lines.mkString 168 | Seq(Chunk.wrappable(indent, text).withPrefix("> ")) ++ EmptyChunk 169 | case link: Link => 170 | Seq(Chunk.wrappable(indent, s"${link.getText} (${link.getUrl})")) 171 | case code: Code => 172 | Seq(Chunk.wrappable(indent, s"'${code.getText}'")) 173 | case delimited: DelimitedNodeImpl => 174 | Seq(Chunk.wrappable(indent, delimited.getText.toString)) 175 | case text : Text => 176 | Seq(Chunk.wrappable(indent, node.getChars.toString)) 177 | case other => 178 | if (other.hasChildren) other.getChildren.flatMap(c => process(c, indent)).toSeq 179 | else Seq(Chunk.wrappable(indent, other.getChars.toString)) 180 | } 181 | 182 | /** 183 | * Takes a chunk of text representing a paragraph, list item, line in a code block, etc. and 184 | * formats it for output by: 185 | * - prepending the indenting space 186 | * - re-wrapping the text if required (e.g. code block lines don't get wrapped) 187 | * - applying any additional gutter (e.g. to follow-on lines in list items) 188 | */ 189 | private def indentAndWrap(chunk: Chunk): Seq[String] = { 190 | val indent = " " * (indentSize * chunk.indent) 191 | val gutter = indent + (" " * chunk.gutter) 192 | 193 | if (chunk.wrappable) { 194 | val length = this.lineLength - indent.length - chunk.prefix.length 195 | val lines = new ListBuffer[String]() 196 | val words = chunk.text.split("\\s+").iterator.buffered 197 | while (words.hasNext) { 198 | val buffer = new StringBuilder 199 | buffer.append(chunk.prefix) 200 | buffer.append(words.next()).append(" ") // always append at least one word, even if it's too long 201 | while (words.hasNext && buffer.length + words.head.length < length) buffer.append(words.next()).append(" ") 202 | val prefix = if (lines.isEmpty) indent else gutter 203 | lines.append(prefix + MarkDownProcessor.trim(buffer.toString)) 204 | buffer.clear() 205 | } 206 | 207 | lines.toSeq 208 | } 209 | else { 210 | Seq(indent + chunk.text) 211 | } 212 | } 213 | 214 | /** Removes a trailing empty line from a Seq[Line]. */ 215 | private def withoutTrailingEmptyLine(lines: Seq[Chunk]): Seq[Chunk] = { 216 | if (lines.isEmpty || lines.last.nonEmpty) lines 217 | else lines.dropRight(1) 218 | } 219 | 220 | /** Debugging method to simply print out the tree-structure of a MarkDown document. */ 221 | def toTree(node: Node): String = { 222 | /** Inner recursive method. */ 223 | def subtree(node: Node, level: Int = 0, buffer: StringBuilder): Unit = { 224 | val indent = " " * (level * 2) 225 | buffer.append(indent).append(node.getClass.getSimpleName).append('\n') 226 | node.getChildren.foreach(child => subtree(child, level + 1, buffer)) 227 | } 228 | 229 | val buffer = new StringBuilder 230 | subtree(node, 0, buffer) 231 | buffer.toString() 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/test/scala/com/fulcrumgenomics/sopt/parsing/ArgTokenizerTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2015-2016 Fulcrum Genomics LLC 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | package com.fulcrumgenomics.sopt.parsing 26 | 27 | import java.nio.file.Files 28 | 29 | import com.fulcrumgenomics.commons.io.Io 30 | import com.fulcrumgenomics.sopt.util.UnitSpec 31 | 32 | import scala.util.{Failure, Success} 33 | 34 | class ArgTokenizerTest extends UnitSpec { 35 | import ArgTokenizer._ 36 | 37 | /** Creates a temporary directory, writes the lines to it, then returns the path as a String. */ 38 | private def writeTmpArgFile(lines: Seq[String]) : String = { 39 | val tmp = Files.createTempFile("args.", ".txt") 40 | tmp.toFile.deleteOnExit() 41 | Io.writeLines(tmp, lines) 42 | tmp.toAbsolutePath.toString 43 | } 44 | 45 | "ArgTokenizer" should "iterate nothing if given no args" in { 46 | new ArgTokenizer().hasNext() shouldBe false 47 | an[NoSuchElementException] should be thrownBy new ArgTokenizer().next() 48 | } 49 | 50 | it should "return an [[EmptyArgumentException]] if an empty string is given" in { 51 | val tryVal = new ArgTokenizer("").next() 52 | tryVal shouldBe Symbol("failure") 53 | an[OptionNameException] should be thrownBy (throw tryVal.failed.get) 54 | } 55 | 56 | it should "tokenize a short option: '-f'" in { 57 | val tokenizer = new ArgTokenizer("-f") 58 | tokenizer.next().get shouldBe ArgOption(name="f") 59 | tokenizer.hasNext() shouldBe false 60 | } 61 | 62 | it should "tokenize a short option: '-f value'" in { 63 | val tokenizer = new ArgTokenizer("-f", "value") 64 | tokenizer.next().get shouldBe ArgOption(name = "f") 65 | tokenizer.next().get shouldBe ArgValue(value = "value") 66 | tokenizer.hasNext() shouldBe false 67 | } 68 | 69 | it should "tokenize a short option: '-fvalue'" in { 70 | val tokenizer = new ArgTokenizer("-fvalue") 71 | tokenizer.next().get shouldBe ArgOptionAndValue(name = "f", value = "value") 72 | tokenizer.hasNext() shouldBe false 73 | } 74 | 75 | it should "tokenize a short option: '-f=value'" in { 76 | val tokenizer = new ArgTokenizer("-f=value") 77 | tokenizer.next().get shouldBe ArgOptionAndValue(name = "f", value = "value") 78 | tokenizer.hasNext() shouldBe false 79 | } 80 | 81 | it should "tokenize a short option: '-fvalue value'" in { 82 | val tokenizer = new ArgTokenizer("-f=value", "value") 83 | tokenizer.next().get shouldBe ArgOptionAndValue(name = "f", value = "value") 84 | tokenizer.next().get shouldBe ArgValue(value="value") 85 | tokenizer.hasNext() shouldBe false 86 | } 87 | 88 | it should "tokenize a short option: '-f value value'" in { 89 | val tokenizer = new ArgTokenizer("-f", "value", "value") 90 | tokenizer.next().get shouldBe ArgOption(name = "f") 91 | tokenizer.next().get shouldBe ArgValue(value="value") 92 | tokenizer.next().get shouldBe ArgValue(value="value") 93 | tokenizer.hasNext() shouldBe false 94 | } 95 | 96 | it should "tokenize a short option: '-f=value value'" in { 97 | val tokenizer = new ArgTokenizer("-f=value", "value") 98 | tokenizer.next().get shouldBe ArgOptionAndValue(name = "f", value = "value") 99 | tokenizer.next().get shouldBe ArgValue(value="value") 100 | tokenizer.hasNext() shouldBe false 101 | } 102 | 103 | it should "tokenize a long option: '--long'" in { 104 | val tokenizer = new ArgTokenizer("--long") 105 | tokenizer.next().get shouldBe ArgOption(name = "long") 106 | tokenizer.hasNext() shouldBe false 107 | } 108 | 109 | it should "tokenize a long option: '--long value'" in { 110 | val tokenizer = new ArgTokenizer("--long", "value") 111 | tokenizer.next().get shouldBe ArgOption(name = "long") 112 | tokenizer.next().get shouldBe ArgValue(value = "value") 113 | tokenizer.hasNext() shouldBe false 114 | } 115 | 116 | it should "not tokenize a long option: '--longvalue'" in { 117 | val tokenizer = new ArgTokenizer("--longvalue") 118 | tokenizer.next().get shouldBe ArgOption(name = "longvalue") 119 | tokenizer.hasNext() shouldBe false 120 | } 121 | 122 | it should "tokenize a long option: '--long=value'" in { 123 | val tokenizer = new ArgTokenizer("--long=value") 124 | tokenizer.next().get shouldBe ArgOptionAndValue(name = "long", value="value") 125 | tokenizer.hasNext() shouldBe false 126 | } 127 | 128 | it should "tokenize a long option: '--long=v'" in { 129 | val tokenizer = new ArgTokenizer("--long=v") 130 | tokenizer.next().get shouldBe ArgOptionAndValue(name = "long", value="v") 131 | tokenizer.hasNext() shouldBe false 132 | } 133 | 134 | it should "tokenize a long option: '--long value value'" in { 135 | val tokenizer = new ArgTokenizer("--long", "value", "value") 136 | tokenizer.next().get shouldBe ArgOption(name = "long") 137 | tokenizer.next().get shouldBe ArgValue(value = "value") 138 | tokenizer.next().get shouldBe ArgValue(value = "value") 139 | tokenizer.hasNext() shouldBe false 140 | } 141 | 142 | it should "tokenize a long option: '--long=value value'" in { 143 | val tokenizer = new ArgTokenizer("--long=value", "value") 144 | tokenizer.next().get shouldBe ArgOptionAndValue(name = "long", value="value") 145 | tokenizer.next().get shouldBe ArgValue(value = "value") 146 | tokenizer.hasNext() shouldBe false 147 | } 148 | 149 | it should "return an ArgValue when missing a leading dash" in { 150 | val argList = List( 151 | ("fvalue", "value"), 152 | ("f=value", "value"), 153 | ("long=value", "value") 154 | ) 155 | argList.foreach { args => 156 | val tokenizer = new ArgTokenizer(args._1, args._2) 157 | tokenizer.next().get shouldBe ArgValue(value=args._1) 158 | tokenizer.next().get shouldBe ArgValue(value=args._2) 159 | tokenizer.hasNext() shouldBe false 160 | } 161 | } 162 | 163 | it should "throw an [[OptionNameException]] when missing a character after a leading dash" in { 164 | val argList = List( 165 | List("-"), 166 | List("-", "value"), 167 | List("-", "value", "-u", "value") 168 | ) 169 | argList.foreach { args => 170 | val tokenizer = new ArgTokenizer(args:_*) 171 | val tryVal = tokenizer.next() 172 | tryVal shouldBe Symbol("failure") 173 | an[OptionNameException] should be thrownBy (throw tryVal.failed.get) 174 | tokenizer.takeRemaining shouldBe args 175 | } 176 | } 177 | 178 | it should "throw an [[OptionNameException]] when a long option ends with an equals ('--long=')" in { 179 | val tokenizer = new ArgTokenizer("--long=") 180 | val tryVal = tokenizer.next() 181 | tryVal shouldBe Symbol("failure") 182 | an[OptionNameException] should be thrownBy (throw tryVal.failed.get) 183 | } 184 | 185 | it should "tokenize a up until '--' is found" in { 186 | val tokenizer = new ArgTokenizer("--long=value", "value", "--", "value") 187 | tokenizer.next().get shouldBe ArgOptionAndValue(name="long", value="value") 188 | tokenizer.next().get shouldBe ArgValue(value = "value") 189 | tokenizer.hasNext() shouldBe false 190 | tokenizer.takeRemaining shouldBe List("value") 191 | } 192 | 193 | it should "tokenize '---foo' into the option '-foo'" in { 194 | val tokenizer = new ArgTokenizer("---foo") 195 | tokenizer.next().get shouldBe ArgOption(name="-foo") // FIXME 196 | } 197 | 198 | it should "tokenize '-@' into the option '@'" in { 199 | val tokenizer = new ArgTokenizer("-@") 200 | tokenizer.next().get shouldBe ArgOption(name="@") // FIXME 201 | } 202 | 203 | it should "tokenize '-f-1' into the option and value 'f' and '-1'" in { 204 | val tokenizer = new ArgTokenizer("-f-1") 205 | tokenizer.next().get shouldBe ArgOptionAndValue(name="f", value="-1") // FIXME 206 | } 207 | 208 | it should "substitute values from a file" in { 209 | val path = writeTmpArgFile(Seq("--foo=oof", "--bar", "1 2 3")) 210 | val tokenizer = new ArgTokenizer(Seq("--hello", "@" + path, "--good=bye"), Some("@")) 211 | tokenizer.next() shouldBe Success(ArgOption(name="hello")) 212 | tokenizer.next() shouldBe Success(ArgOptionAndValue(name="foo", value="oof")) 213 | tokenizer.next() shouldBe Success(ArgOption(name="bar")) 214 | tokenizer.next() shouldBe Success(ArgValue(value="1 2 3")) 215 | tokenizer.next() shouldBe Success(ArgOptionAndValue(name="good", value="bye")) 216 | } 217 | 218 | it should "handle an empty arguments file" in { 219 | val path = writeTmpArgFile(Seq()) 220 | val tokenizer = new ArgTokenizer(Seq("--hello", "@" + path, "--good=bye"), Some("@")) 221 | tokenizer.next() shouldBe Success(ArgOption(name="hello")) 222 | tokenizer.next() shouldBe Success(ArgOptionAndValue(name="good", value="bye")) 223 | } 224 | 225 | it should "handle an arguments file with empty and whitespace-only lines" in { 226 | val path = writeTmpArgFile(Seq("--foo", "", " ", "--bar")) 227 | val tokenizer = new ArgTokenizer(Seq("--hello", "@" + path, "--good=bye"), Some("@")) 228 | tokenizer.next() shouldBe Success(ArgOption(name="hello")) 229 | tokenizer.next() shouldBe Success(ArgOption(name="foo")) 230 | tokenizer.next() shouldBe Success(ArgOption(name="bar")) 231 | tokenizer.next() shouldBe Success(ArgOptionAndValue(name="good", value="bye")) 232 | } 233 | 234 | it should "trim leading and trailing whitespace from argument file lines" in { 235 | val path = writeTmpArgFile(Seq(" --foo", "--bar ", " --whee ")) 236 | val tokenizer = new ArgTokenizer(Seq("--hello", "@" + path, "--good=bye"), Some("@")) 237 | tokenizer.next() shouldBe Success(ArgOption(name="hello")) 238 | tokenizer.next() shouldBe Success(ArgOption(name="foo")) 239 | tokenizer.next() shouldBe Success(ArgOption(name="bar")) 240 | tokenizer.next() shouldBe Success(ArgOption(name="whee")) 241 | tokenizer.next() shouldBe Success(ArgOptionAndValue(name="good", value="bye")) 242 | } 243 | 244 | it should "recursive parse arg files" in { 245 | val path1 = writeTmpArgFile(Seq("--three")) 246 | val path2 = writeTmpArgFile(Seq("--two", "@" + path1, "--four")) 247 | val path3 = writeTmpArgFile(Seq("--one", "@" + path2, "--five")) 248 | 249 | val tokenizer = new ArgTokenizer(Seq("--zero", "@" + path3, "--six"), Some("@")) 250 | tokenizer.next() shouldBe Success(ArgOption(name="zero")) 251 | tokenizer.next() shouldBe Success(ArgOption(name="one")) 252 | tokenizer.next() shouldBe Success(ArgOption(name="two")) 253 | tokenizer.next() shouldBe Success(ArgOption(name="three")) 254 | tokenizer.next() shouldBe Success(ArgOption(name="four")) 255 | tokenizer.next() shouldBe Success(ArgOption(name="five")) 256 | tokenizer.next() shouldBe Success(ArgOption(name="six")) 257 | } 258 | 259 | it should "return a Failure when hitting an argment file that doesn't exist" in { 260 | val path = "/path/to/nowhere/I/mean_it/12345.txt" 261 | 262 | val tokenizer = new ArgTokenizer(Seq("--one", "--two=three", "-f", "@" + path, "--six"), Some("@")) 263 | tokenizer.next() shouldBe Success(ArgOption(name="one")) 264 | tokenizer.next() shouldBe Success(ArgOptionAndValue(name="two", value="three")) 265 | tokenizer.next() shouldBe Success(ArgOption(name="f")) 266 | tokenizer.next() shouldBe an[Failure[_]] 267 | tokenizer.takeRemaining shouldBe Seq("@" + path, "--six") 268 | } 269 | } 270 | --------------------------------------------------------------------------------