Skip to content
10 changes: 7 additions & 3 deletions modules/build/src/main/scala/scala/build/ScopedSources.scala
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ final case class ScopedSources(
Sources.InMemory(
Left("build-info"),
os.rel / "BuildInfo.scala",
value(buildInfo(combinedOptions, workspace)).generateContents().getBytes(
value(buildInfo(combinedOptions, workspace, logger)).generateContents().getBytes(
StandardCharsets.UTF_8
),
None
Expand Down Expand Up @@ -123,7 +123,11 @@ final case class ScopedSources(
buildOptionsFor(scope)
.foldRight(baseOptions)(_.orElse(_))

def buildInfo(baseOptions: BuildOptions, workspace: os.Path): Either[BuildException, BuildInfo] =
def buildInfo(
baseOptions: BuildOptions,
workspace: os.Path,
logger: Logger
): Either[BuildException, BuildInfo] =
either {
def getScopedBuildInfo(scope: Scope): ScopedBuildInfo =
val combinedOptions = combinedBuildOptions(scope, baseOptions)
Expand All @@ -133,7 +137,7 @@ final case class ScopedSources(
unwrappedScripts.flatMap(_.valueFor(scope).toSeq).flatMap(_.originalPath.toOption))
.map(_._2.toString)

ScopedBuildInfo(combinedOptions, sourcePaths ++ inMemoryPaths)
ScopedBuildInfo.forScope(combinedOptions, sourcePaths ++ inMemoryPaths, scope, logger)

val baseBuildInfo = value(BuildInfo(combinedBuildOptions(Scope.Main, baseOptions), workspace))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class ScalaCliCommands(
new HelpCmd(help),
installcompletions.InstallCompletions,
installhome.InstallHome,
listtargets.ListTargets,
`new`.New,
repl.Repl,
package0.Package,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package scala.cli.commands.listtargets

import caseapp.*
import com.github.plokhotnyuk.jsoniter_scala.core.{JsonValueCodec, WriterConfig, writeToStream}
import com.github.plokhotnyuk.jsoniter_scala.macros.JsonCodecMaker

import scala.build.*
import scala.build.errors.BuildException
import scala.build.input.Inputs
import scala.build.options.BuildOptions
import scala.cli.CurrentParams
import scala.cli.commands.shared.SharedOptions
import scala.cli.commands.{ScalaCommand, SpecificationLevel}

object ListTargets extends ScalaCommand[ListTargetsOptions] {
override def scalaSpecificationLevel: SpecificationLevel = SpecificationLevel.EXPERIMENTAL
override def names: List[List[String]] = List(
List("list-targets")
)
override def sharedOptions(options: ListTargetsOptions): Option[SharedOptions] =
Some(options.shared)

private final case class TargetEntry(
platform: String,
scalaVersion: Option[String]
)

private given JsonValueCodec[List[TargetEntry]] = JsonCodecMaker.make

private def loadCrossSources(
inputs: Inputs,
buildOptions: BuildOptions,
logger: Logger
): Either[BuildException, CrossSources] =
CrossSources.forInputs(
inputs,
Sources.defaultPreprocessors(
buildOptions.archiveCache,
buildOptions.internal.javaClassNameVersionOpt,
() => buildOptions.javaHome().value.javaCommand
),
logger,
buildOptions.suppressWarningOptions,
buildOptions.internal.exclude,
download = buildOptions.downloader
).map(_._1)

private def targetOf(options: BuildOptions): TargetEntry = {
val platform = options.platform.value.repr
val sv = options.scalaParams.toOption.flatten.map(_.scalaVersion)
.orElse(options.scalaOptions.scalaVersion.flatMap(_.versionOpt))
.orElse(options.scalaOptions.defaultScalaVersion)
TargetEntry(platform, sv)
}

override def runCommand(
options: ListTargetsOptions,
args: RemainingArgs,
logger: Logger
): Unit = {
val initialBuildOptions = buildOptionsOrExit(options)
val inputs = options.shared.inputs(args.all).orExit(logger)
CurrentParams.workspaceOpt = Some(inputs.workspace)

val crossSources = loadCrossSources(inputs, initialBuildOptions, logger).orExit(logger)
val resolvedOptions = crossSources.sharedOptions(initialBuildOptions)

val targets = (resolvedOptions +: resolvedOptions.crossOptions).map(targetOf).distinct.toList

writeToStream(targets, System.out, WriterConfig.withIndentionStep(1))
System.out.println()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package scala.cli.commands.listtargets

import caseapp.*

import scala.cli.commands.shared.{HasSharedOptions, SharedOptions}

@HelpMessage(ListTargetsOptions.helpMessage, "", ListTargetsOptions.detailedHelpMessage)
final case class ListTargetsOptions(
@Recurse
shared: SharedOptions = SharedOptions()
) extends HasSharedOptions

object ListTargetsOptions {
implicit lazy val parser: Parser[ListTargetsOptions] = Parser.derive
implicit lazy val help: Help[ListTargetsOptions] = Help.derive

private val helpHeader =
"Print the full matrix of declared build targets (platform x Scala version) as JSON."
val helpMessage: String = helpHeader
val detailedHelpMessage: String =
s"""$helpHeader
|
|Reads `using` directives and CLI options from the inputs and emits one entry per
|declared target, so external tools can enumerate the matrix without parsing
|directives themselves.
|
|Each entry has shape `{ "platform": "JVM"|"JS"|"Native", "scalaVersion": "..." }`.
|The `scalaVersion` field is omitted for pure-Java projects.""".stripMargin
}
25 changes: 23 additions & 2 deletions modules/cli/src/main/scala/scala/cli/exportCmd/JsonProject.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@ import com.github.plokhotnyuk.jsoniter_scala.macros.JsonCodecMaker

import java.io.PrintStream

import scala.build.info.{BuildInfo, ExportDependencyFormat, ScopedBuildInfo}
import scala.build.info.{
BuildInfo,
ExportDependencyFormat,
JsOptionsInfo,
NativeOptionsInfo,
ScopedBuildInfo
}
import scala.util.Using

final case class JsonProject(buildInfo: BuildInfo) extends Project {
def sorted = this.copy(
buildInfo = buildInfo.copy(
scopes = buildInfo.scopes.map { case (k, v) => k -> v.sorted }
scopes = buildInfo.scopes.map { case (k, v) => k -> v.sorted },
nativeOptions = buildInfo.nativeOptions.map(_.sorted),
jsOptions = buildInfo.jsOptions.map(_.sorted)
)
)

Expand Down Expand Up @@ -49,13 +57,26 @@ final case class JsonProject(buildInfo: BuildInfo) extends Project {
}
}

extension (n: NativeOptionsInfo) {
def sorted(using ord: Ordering[String]) = n.copy(
toolingDependencies = n.toolingDependencies.sorted(using JsonProject.ordering)
)
}

extension (j: JsOptionsInfo) {
def sorted(using ord: Ordering[String]) = j.copy(
toolingDependencies = j.toolingDependencies.sorted(using JsonProject.ordering)
)
}

extension (s: ScopedBuildInfo) {
def sorted(using ord: Ordering[String]) = s.copy(
s.sources.sorted,
s.scalacOptions.sorted,
s.scalaCompilerPlugins.sorted(using JsonProject.ordering),
s.dependencies.sorted(using JsonProject.ordering),
s.compileOnlyDependencies.sorted(using JsonProject.ordering),
s.injectedDependencies.sorted(using JsonProject.ordering),
s.resolvers.sorted,
s.resourceDirs.sorted,
s.customJarsDecls.sorted
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,22 @@ final case class JsonProjectDescriptor(
sourcesMain: Sources,
sourcesTest: Sources
): Either[BuildException, JsonProject] = {
def getScopedBuildInfo(options: BuildOptions, sources: Sources) =
def getScopedBuildInfo(options: BuildOptions, sources: Sources, scope: Scope) =
val sourcePaths = sources.paths.map(_._1.toString)
val inMemoryPaths = sources.inMemory.flatMap(_.originalPath.toSeq.map(_._2.toString))

ScopedBuildInfo(options, sourcePaths ++ inMemoryPaths)
ScopedBuildInfo.forScope(
options,
sourcePaths ++ inMemoryPaths,
scope,
logger,
injectTestRunner = true
)

for {
baseBuildInfo <- BuildInfo(optionsMain, workspace)
mainBuildInfo = getScopedBuildInfo(optionsMain, sourcesMain)
testBuildInfo = getScopedBuildInfo(optionsTest, sourcesTest)
mainBuildInfo = getScopedBuildInfo(optionsMain, sourcesMain, Scope.Main)
testBuildInfo = getScopedBuildInfo(optionsTest, sourcesTest, Scope.Test)
} yield JsonProject(baseBuildInfo
.withScope(Scope.Main.name, mainBuildInfo)
.withScope(Scope.Test.name, testBuildInfo))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,15 @@ object ScalaJsLinker {
val scalaJsCliVersion = options.finalScalaJsCliVersion
val scalaJsCliDep = {
val mod = mod"org.virtuslab.scala-cli:scalajscli_2.13"
dependency.Dependency(mod, s"$scalaJsCliVersion+")
// When the CLI version was set explicitly (e.g. `--js-cli-version`),
// pin it exactly. The default uses a `+` range so a newer patch can
// win, but an explicit version is treated as a hard pin — this is
// what lets an offline/locked build resolve the linker without the
// version-listing metadata that range resolution would require.
val constraint =
if (options.scalaJsCliVersion.isDefined) scalaJsCliVersion
else s"$scalaJsCliVersion+"
dependency.Dependency(mod, constraint)
}

val forcedVersions = Seq(
Expand Down
Loading
Loading