Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[proposal] ScalacOption class based on types #18

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright 2022 Typelevel
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.typelevel.scalacoptions.proposal

// TODO: TBD
sealed abstract class ParseFailure

object ParseFailure {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* Copyright 2022 Typelevel
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.typelevel.scalacoptions.proposal

import org.typelevel.scalacoptions.ScalaVersion

trait ScalacOption[A] {
type Type <: ScalacOption.Type

// ..or `isSupportedFor` maybe?
def isSupported(sv: ScalaVersion): Boolean

/** Base option name.
*
* @return
* - for `"-deprecation"` results to `"deprecation"`
* - for `"-deprecation:true"` results to `"deprecation"`
* - for `"-Xlint"` results to `"Xlint"`
* - for `"-Xlint:deprecation"` results to `"Xlint"`
*/
def baseName: String

// /** Full option name.
// *
// * @return
// * - for `"-deprecation"` results to `"deprecation"`
// * - for `"-deprecation:true"` results to `"deprecation"` (same as above!)
// * - for `"-Xlint"` results to `"Xlint"`
// * - for `"-Xlint:deprecation"` results to `"Xlint:deprecation"`
// */
// def fullName: String

/** Parses the option from a raw value found for the option's base name.
*
* @param rawValue
* a raw option value, can be empty strings for options like `-deprecation`
*
* @return
* - `None` if the value does not belong to the option and thus cannot be parsed, i.e. when
* `"unused"` is passed where `-Xlint:deprecation` model is expected;
* - `Some(A)` when a correct value is passed for the option, e.g. both `"unused"` and
* `"-unused"` will be accepted for the an option type class that models the `-Xlint:unused`
* option.
*
* @note
* It deliberately made not returning any failure type like `ParseFailure`, becase
* `ScalacOption.Select` is responsible for that.
*/
def parse(rawValue: String): Option[A]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I think it should be rawValue: Seq[String]

}

object ScalacOption {
type Aux[A, T <: Type] = ScalacOption[A] { type Type = T }

// Note: initially I thought that Singe/Recurring might not be necessary.
// But later I realized that `Recurring` can be useful for modeling such options like "-Wconf"
sealed trait Type
abstract final class Single private () extends Type // never instantiated
abstract final class Recurring private () extends Type // never instantiated

sealed trait Select[A] {
// Not sure why it is a higher kinded type in http4s' Header.
// A regular plain type seems to be just enough here.
type Out

/** Parses all raw option values groupped by their [[ScalacOption.baseName]].
*
* @param rawValues
* a list of raw option values in the order they occured in a command line.
*/
def parse(rawValues: List[String]): Option[Out]
}

object Select {
implicit def singleScalacOption[A](implicit
so: ScalacOption.Aux[A, Single]
): Select[A] { type Out = A } =
new Select[A] {
type Out = A

def parse(rawValues: List[String]): Option[A] = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I imagine this method will need to delimit the scalac options without knowing all of them. One easy algorithm is to treat every -something as the beginning of a new option+args.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would say that a more reliable (and performant, actually) way could be pre-parsing all the options at once, and then just picking them by one from the collection of parsed options upon a user request. But it would require a global "registry" of parsers. Which is not that bad, actually, but makes the approach different from how Headers are treated.

The root cause of the difference is kinda "fundamental" – in regards to headers there're RFCs that clearly define how to separate various headers from each other without digging into their contents. But for CLI options there's no such a clear way of separating them 🤷

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But it would require a global "registry" of parsers.

I agree it would be much more reliable, but I would very much like to avoid this for fear this library will completely stop working if it encounters an unknown scalac option. It must be able to cleanly pass-through anything it cannot recognize.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, we definitely should try to make the parser more lenient.
But that should be a fall-back mode, I think. I.e. if the parser cannot recognize something it can try to pass-through assuming that everything not starting with a dash is an argument rather than an option.

It would be a good trade-off between reliability and tolerance.

// Assume for now that for a single-occuring option the last value occured overrides
// all previous values. E.g.: for `Seq("-feature", "-feature:false")` the last one should
// take effect.
rawValues.iterator.flatMap(so.parse).toList.lastOption
}
}

implicit def recurringScalacOption[A](implicit
so: ScalacOption.Aux[A, Recurring]
): Select[A] { type Out = List[A] } =
new Select[A] {
type Out = List[A] // consider NonEmptyList

def parse(rawValues: List[String]): Option[List[A]] = {
// As simple as it is (but with NonEmptyList it would be even simpler).
rawValues.iterator.flatMap(so.parse).toList match {
case Nil => None
case nel => Some(nel) // the result cannot be an empty list!
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* Copyright 2022 Typelevel
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.typelevel.scalacoptions.proposal

/** A collection of scalac options.
*
* @param rawOptions
* raw untyped scalac options groupped by their base name.
*
* @example
* The following list of options:
* {{{
* Seq(
* "-deprecation",
* "-feature:false",
* "-Xlint:deprecation,unused",
* "-Wconf:cat=lint&msg=hello:e,any:i",
* "-Xlint:_,-constant",
* "-Wunused"
* )
* }}}
* will be represented as:
* {{{
* Map(
* "deprecation" -> List(""),
* "feature" -> List("false"),
* "Xlint" -> List("deprecation", "unused", "_", "-constant"),
* "Wconf" -> List("msg=cat=lint&msg=hello", "any:i"),
* "Wunused" -> List("")
* )
* }}}
*
* @note
* Even a single option like `-Xlint` should be kept as a pair of `"Xlint" -> List("")`, because
* it can be important to parse groupped options correctly. It means that the underlying list
* cannot be empty. Thus, the question: can be consider `NonEmptyList` from cats for that?
*/
final class ScalacOptions(rawOptions: Map[String, List[String]]) {

def get[A](implicit opt: ScalacOption[A], sel: ScalacOption.Select[A]): Option[sel.Out] = {
rawOptions
.get(opt.baseName)
.flatMap { sel.parse(_) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* Copyright 2022 Typelevel
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.typelevel.scalacoptions.proposal

class ScalacOptionSuite {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Copyright 2022 Typelevel
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.typelevel.scalacoptions.proposal

import org.scalacheck.Arbitrary
import org.scalacheck.Gen
import org.scalacheck.Prop
import org.typelevel.scalacoptions.ScalaVersion

class ScalacOptionsSuite extends munit.ScalaCheckSuite {
import ScalacOptionsSuite._

private val arbStrGen = Arbitrary.arbitrary[String]
// uncomment to simplify debugging
// TODO: remove!
// private val arbStrGen = Gen.asciiPrintableStr

test("ScalacOptions.get should work for ScalacOption.Single") {
val gen =
for {
targetBaseName <- arbStrGen
targetResultValue <- arbStrGen
targetOtherValues <-
Gen.listOf(
Gen.oneOf(
arbStrGen,
arbStrGen.map(targetResultValue + _)
)
)
otherOptions <- Gen.mapOf(Gen.zip(arbStrGen, Gen.listOf(arbStrGen)))
} yield {
(
targetBaseName,
targetResultValue,
otherOptions + (targetBaseName -> (targetOtherValues :+ targetResultValue))
)
}

Prop.forAll(gen) { case (targetBaseName, targetResultValue, allOptions) =>
implicit val singleScalacOption: ScalacOption.Aux[TestOption, ScalacOption.Single] =
new ScalacOption[TestOption] {
type Type = ScalacOption.Single

override def isSupported(sv: ScalaVersion): Boolean = ??? // not a subject for testing

override def baseName: String = targetBaseName

override def parse(rawValue: String): Option[TestOption] = {
if (rawValue.startsWith(targetResultValue))
Some(TestOption(rawValue))
else
None
}
}

val obtained = new ScalacOptions(allOptions).get[TestOption].map(_.value)

assertEquals(obtained, Some(targetResultValue))
}
}
}

object ScalacOptionsSuite {
final case class TestOption(value: String) extends AnyVal
}