Skip to content

Commit 48eb771

Browse files
committed
add support for pipeline build of Scala and Java files
- rename '-Yjava-tasty-output' to '-Yearly-tasty-output' because now Scala TASTy will also be written to this destination. - add '-Ypickle-java' alias of '-Yjava-tasty', as expected by Zinc - add '-Ypickle-write' alias of '-Yearly-tasty-output', as expected by Zinc - move ExtractAPI phase to after Pickler, this way we can do it in parallel with generating TASTy bytes. At the end of this phase we write the TASTy to the '-Yearly-tasty-output' destination. Also ensure that ExtractAPI phase runs with '-Yjava-tasty', even if no incremental callback is set (don't extract the API in this case). - test the pipelining with sbt scripted tests, including for inline methods and macros with pipelining - describe semantics with respect to suspensions, introduce -Yno-suspended-units flag for greater control by the user.
1 parent df44731 commit 48eb771

File tree

67 files changed

+701
-79
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

67 files changed

+701
-79
lines changed

Diff for: compiler/src/dotty/tools/dotc/CompilationUnit.scala

+9-6
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,15 @@ class CompilationUnit protected (val source: SourceFile, val info: CompilationUn
9797
// when this unit is unsuspended.
9898
depRecorder.clear()
9999
if !suspended then
100-
if (ctx.settings.XprintSuspension.value)
101-
report.echo(i"suspended: $this")
102-
suspended = true
103-
ctx.run.nn.suspendedUnits += this
104-
if ctx.phase == Phases.inliningPhase then
105-
suspendedAtInliningPhase = true
100+
if ctx.settings.YnoSuspendedUnits.value then
101+
report.error(i"Compilation unit suspended $this (-Yno-suspended-units is set)")
102+
else
103+
if (ctx.settings.XprintSuspension.value)
104+
report.echo(i"suspended: $this")
105+
suspended = true
106+
ctx.run.nn.suspendedUnits += this
107+
if ctx.phase == Phases.inliningPhase then
108+
suspendedAtInliningPhase = true
106109
throw CompilationUnit.SuspendException()
107110

108111
private var myAssignmentSpans: Map[Int, List[Span]] | Null = null

Diff for: compiler/src/dotty/tools/dotc/Compiler.scala

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,13 @@ class Compiler {
4141
List(new semanticdb.ExtractSemanticDB.ExtractSemanticInfo) :: // Extract info into .semanticdb files
4242
List(new PostTyper) :: // Additional checks and cleanups after type checking
4343
List(new sjs.PrepJSInterop) :: // Additional checks and transformations for Scala.js (Scala.js only)
44-
List(new sbt.ExtractAPI) :: // Sends a representation of the API of classes to sbt via callbacks
4544
List(new SetRootTree) :: // Set the `rootTreeOrProvider` on class symbols
4645
Nil
4746

4847
/** Phases dealing with TASTY tree pickling and unpickling */
4948
protected def picklerPhases: List[List[Phase]] =
5049
List(new Pickler) :: // Generate TASTY info
50+
List(new sbt.ExtractAPI) :: // Sends a representation of the API of classes to sbt via callbacks
5151
List(new Inlining) :: // Inline and execute macros
5252
List(new PostInlining) :: // Add mirror support for inlined code
5353
List(new CheckUnused.PostInlining) :: // Check for unused elements

Diff for: compiler/src/dotty/tools/dotc/config/ScalaSettings.scala

+5-4
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ object ScalaSettings extends ScalaSettings
3131

3232
// Kept as seperate type to avoid breaking backward compatibility
3333
abstract class ScalaSettings extends SettingGroup, AllScalaSettings:
34-
val settingsByCategory: Map[SettingCategory, List[Setting[_]]] =
34+
val settingsByCategory: Map[SettingCategory, List[Setting[_]]] =
3535
allSettings.groupBy(_.category)
3636
.view.mapValues(_.toList).toMap
3737
.withDefaultValue(Nil)
@@ -43,7 +43,7 @@ abstract class ScalaSettings extends SettingGroup, AllScalaSettings:
4343
val verboseSettings: List[Setting[_]] = settingsByCategory(VerboseSetting).sortBy(_.name)
4444
val settingsByAliases: Map[String, Setting[_]] = allSettings.flatMap(s => s.aliases.map(_ -> s)).toMap
4545

46-
46+
4747
trait AllScalaSettings extends CommonScalaSettings, PluginSettings, VerboseSettings, WarningSettings, XSettings, YSettings:
4848
self: SettingGroup =>
4949

@@ -380,6 +380,7 @@ private sealed trait YSettings:
380380
val YprintPos: Setting[Boolean] = BooleanSetting(ForkSetting, "Yprint-pos", "Show tree positions.")
381381
val YprintPosSyms: Setting[Boolean] = BooleanSetting(ForkSetting, "Yprint-pos-syms", "Show symbol definitions positions.")
382382
val YnoDeepSubtypes: Setting[Boolean] = BooleanSetting(ForkSetting, "Yno-deep-subtypes", "Throw an exception on deep subtyping call stacks.")
383+
val YnoSuspendedUnits: Setting[Boolean] = BooleanSetting(ForkSetting, "Yno-suspended-units", "Do not suspend units, e.g. when calling a macro defined in the same run. This will error instead of suspending.")
383384
val YnoPatmatOpt: Setting[Boolean] = BooleanSetting(ForkSetting, "Yno-patmat-opt", "Disable all pattern matching optimizations.")
384385
val YplainPrinter: Setting[Boolean] = BooleanSetting(ForkSetting, "Yplain-printer", "Pretty-print using a plain printer.")
385386
val YprintSyms: Setting[Boolean] = BooleanSetting(ForkSetting, "Yprint-syms", "When printing trees print info in symbols instead of corresponding info in trees.")
@@ -439,7 +440,7 @@ private sealed trait YSettings:
439440
val YdebugMacros: Setting[Boolean] = BooleanSetting(ForkSetting, "Ydebug-macros", "Show debug info when quote pattern match fails")
440441

441442
// Pipeline compilation options
442-
val YjavaTasty: Setting[Boolean] = BooleanSetting(ForkSetting, "Yjava-tasty", "Pickler phase should compute pickles for .java defined symbols for use by build tools")
443-
val YjavaTastyOutput: Setting[AbstractFile] = OutputSetting(ForkSetting, "Yjava-tasty-output", "directory|jar", "(Internal use only!) destination for generated .tasty files containing Java type signatures.", NoAbstractFile)
443+
val YjavaTasty: Setting[Boolean] = BooleanSetting(ForkSetting, "Yjava-tasty", "Pickler phase should compute TASTy for .java defined symbols for use by build tools", aliases = List("-Ypickle-java"))
444+
val YearlyTastyOutput: Setting[AbstractFile] = OutputSetting(ForkSetting, "Yearly-tasty-output", "directory|jar", "Destination to write generated .tasty files to for use in pipelined compilation.", NoAbstractFile, aliases = List("-Ypickle-write"))
444445
val YallowOutlineFromTasty: Setting[Boolean] = BooleanSetting(ForkSetting, "Yallow-outline-from-tasty", "Allow outline TASTy to be loaded with the -from-tasty option.")
445446
end YSettings

Diff for: compiler/src/dotty/tools/dotc/config/Settings.scala

+2-2
Original file line numberDiff line numberDiff line change
@@ -357,8 +357,8 @@ object Settings:
357357
def MultiStringSetting(category: SettingCategory, name: String, helpArg: String, descr: String, default: List[String] = Nil, aliases: List[String] = Nil): Setting[List[String]] =
358358
publish(Setting(category, prependName(name), descr, default, helpArg, aliases = aliases))
359359

360-
def OutputSetting(category: SettingCategory, name: String, helpArg: String, descr: String, default: AbstractFile): Setting[AbstractFile] =
361-
publish(Setting(category, prependName(name), descr, default, helpArg))
360+
def OutputSetting(category: SettingCategory, name: String, helpArg: String, descr: String, default: AbstractFile, aliases: List[String] = Nil): Setting[AbstractFile] =
361+
publish(Setting(category, prependName(name), descr, default, helpArg, aliases = aliases))
362362

363363
def PathSetting(category: SettingCategory, name: String, descr: String, default: String, aliases: List[String] = Nil): Setting[String] =
364364
publish(Setting(category, prependName(name), descr, default, aliases = aliases))

Diff for: compiler/src/dotty/tools/dotc/core/Definitions.scala

+1
Original file line numberDiff line numberDiff line change
@@ -1065,6 +1065,7 @@ class Definitions {
10651065
@tu lazy val PublicInBinaryAnnot: ClassSymbol = requiredClass("scala.annotation.publicInBinary")
10661066

10671067
@tu lazy val JavaRepeatableAnnot: ClassSymbol = requiredClass("java.lang.annotation.Repeatable")
1068+
@tu lazy val JavaAnnotationAnnot: ClassSymbol = requiredClass("java.lang.annotation.Annotation")
10681069

10691070
// Initialization annotations
10701071
@tu lazy val InitModule: Symbol = requiredModule("scala.annotation.init")

Diff for: compiler/src/dotty/tools/dotc/core/Phases.scala

+17-3
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ object Phases {
210210
private var myTyperPhase: Phase = uninitialized
211211
private var myPostTyperPhase: Phase = uninitialized
212212
private var mySbtExtractDependenciesPhase: Phase = uninitialized
213+
private var mySbtExtractAPIPhase: Phase = uninitialized
213214
private var myPicklerPhase: Phase = uninitialized
214215
private var myInliningPhase: Phase = uninitialized
215216
private var myStagingPhase: Phase = uninitialized
@@ -235,6 +236,7 @@ object Phases {
235236
final def typerPhase: Phase = myTyperPhase
236237
final def postTyperPhase: Phase = myPostTyperPhase
237238
final def sbtExtractDependenciesPhase: Phase = mySbtExtractDependenciesPhase
239+
final def sbtExtractAPIPhase: Phase = mySbtExtractAPIPhase
238240
final def picklerPhase: Phase = myPicklerPhase
239241
final def inliningPhase: Phase = myInliningPhase
240242
final def stagingPhase: Phase = myStagingPhase
@@ -263,6 +265,7 @@ object Phases {
263265
myTyperPhase = phaseOfClass(classOf[TyperPhase])
264266
myPostTyperPhase = phaseOfClass(classOf[PostTyper])
265267
mySbtExtractDependenciesPhase = phaseOfClass(classOf[sbt.ExtractDependencies])
268+
mySbtExtractAPIPhase = phaseOfClass(classOf[sbt.ExtractAPI])
266269
myPicklerPhase = phaseOfClass(classOf[Pickler])
267270
myInliningPhase = phaseOfClass(classOf[Inlining])
268271
myStagingPhase = phaseOfClass(classOf[Staging])
@@ -336,19 +339,29 @@ object Phases {
336339
/** skip the phase for a Java compilation unit, may depend on -Yjava-tasty */
337340
def skipIfJava(using Context): Boolean = true
338341

342+
final def isAfterLastJavaPhase(using Context): Boolean =
343+
// With `-Yjava-tasty` nominally the final phase is expected be ExtractAPI,
344+
// otherwise drop Java sources at the end of TyperPhase.
345+
// Checks if the last Java phase is before this phase,
346+
// which always fails if the terminal phase is before lastJavaPhase.
347+
val lastJavaPhase = if ctx.settings.YjavaTasty.value then sbtExtractAPIPhase else typerPhase
348+
lastJavaPhase <= this
349+
339350
/** @pre `isRunnable` returns true */
340351
def run(using Context): Unit
341352

342353
/** @pre `isRunnable` returns true */
343354
def runOn(units: List[CompilationUnit])(using runCtx: Context): List[CompilationUnit] =
344355
val buf = List.newBuilder[CompilationUnit]
345-
// factor out typedAsJava check when not needed
346-
val doSkipJava = ctx.settings.YjavaTasty.value && this <= picklerPhase && skipIfJava
356+
357+
// Test that we are in a state where we need to check if the phase should be skipped for a java file,
358+
// this prevents checking the expensive `unit.typedAsJava` unnecessarily.
359+
val doCheckJava = skipIfJava && !isAfterLastJavaPhase
347360
for unit <- units do
348361
given unitCtx: Context = runCtx.fresh.setPhase(this.start).setCompilationUnit(unit).withRootImports
349362
if ctx.run.enterUnit(unit) then
350363
try
351-
if doSkipJava && unit.typedAsJava then
364+
if doCheckJava && unit.typedAsJava then
352365
()
353366
else
354367
run
@@ -503,6 +516,7 @@ object Phases {
503516
def typerPhase(using Context): Phase = ctx.base.typerPhase
504517
def postTyperPhase(using Context): Phase = ctx.base.postTyperPhase
505518
def sbtExtractDependenciesPhase(using Context): Phase = ctx.base.sbtExtractDependenciesPhase
519+
def sbtExtractAPIPhase(using Context): Phase = ctx.base.sbtExtractAPIPhase
506520
def picklerPhase(using Context): Phase = ctx.base.picklerPhase
507521
def inliningPhase(using Context): Phase = ctx.base.inliningPhase
508522
def stagingPhase(using Context): Phase = ctx.base.stagingPhase

Diff for: compiler/src/dotty/tools/dotc/core/SymbolLoaders.scala

+2-1
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,8 @@ class TastyLoader(val tastyFile: AbstractFile) extends SymbolLoader {
456456
val tastyUUID = unpickler.unpickler.header.uuid
457457
new ClassfileTastyUUIDParser(classfile)(ctx).checkTastyUUID(tastyUUID)
458458
else
459-
// This will be the case in any of our tests that compile with `-Youtput-only-tasty`
459+
// This will be the case in any of our tests that compile with `-Youtput-only-tasty`, or when
460+
// tasty file compiled by `-Yearly-tasty-output-write` comes from an early output jar.
460461
report.inform(s"No classfiles found for $tastyFile when checking TASTy UUID")
461462

462463
private def mayLoadTreesFromTasty(using Context): Boolean =

Diff for: compiler/src/dotty/tools/dotc/inlines/Inliner.scala

+3
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,9 @@ class Inliner(val call: tpd.Tree)(using Context):
10421042
for sym <- dependencies do
10431043
if ctx.compilationUnit.source.file == sym.associatedFile then
10441044
report.error(em"Cannot call macro $sym defined in the same source file", call.srcPos)
1045+
else if ctx.settings.YnoSuspendedUnits.value then
1046+
val addendum = ", suspension prevented by -Yno-suspended-units"
1047+
report.error(em"Cannot call macro $sym defined in the same compilation run$addendum", call.srcPos)
10451048
if (suspendable && ctx.settings.XprintSuspension.value)
10461049
report.echo(i"suspension triggered by macro call to ${sym.showLocated} in ${sym.associatedFile}", call.srcPos)
10471050
if suspendable then

Diff for: compiler/src/dotty/tools/dotc/sbt/ExtractAPI.scala

+66-6
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import StdNames.str
1919
import NameOps.*
2020
import inlines.Inlines
2121
import transform.ValueClasses
22+
import transform.Pickler
2223
import dotty.tools.io.{File, FileExtension, JarArchive}
2324
import util.{Property, SourceFile}
2425
import java.io.PrintWriter
@@ -51,7 +52,7 @@ class ExtractAPI extends Phase {
5152
override def description: String = ExtractAPI.description
5253

5354
override def isRunnable(using Context): Boolean = {
54-
super.isRunnable && ctx.runZincPhases
55+
super.isRunnable && (ctx.runZincPhases || ctx.settings.YjavaTasty.value)
5556
}
5657

5758
// Check no needed. Does not transform trees
@@ -65,16 +66,75 @@ class ExtractAPI extends Phase {
6566
// after `PostTyper` (unlike `ExtractDependencies`, the simplication to trees
6667
// done by `PostTyper` do not affect this phase because it only cares about
6768
// definitions, and `PostTyper` does not change definitions).
68-
override def runsAfter: Set[String] = Set(transform.PostTyper.name)
69+
override def runsAfter: Set[String] = Set(transform.Pickler.name)
6970

7071
override def runOn(units: List[CompilationUnit])(using Context): List[CompilationUnit] =
72+
val doZincCallback = ctx.runZincPhases
73+
val sigWriter: Option[Pickler.EarlyFileWriter] = ctx.settings.YearlyTastyOutput.value match
74+
case earlyOut if earlyOut.isDirectory && earlyOut.exists =>
75+
Some(Pickler.EarlyFileWriter(earlyOut))
76+
case _ =>
77+
None
7178
val nonLocalClassSymbols = new mutable.HashSet[Symbol]
72-
val ctx0 = ctx.withProperty(NonLocalClassSymbolsInCurrentUnits, Some(nonLocalClassSymbols))
73-
val units0 = super.runOn(units)(using ctx0)
74-
ctx.withIncCallback(recordNonLocalClasses(nonLocalClassSymbols, _))
75-
units0
79+
val units0 =
80+
if doZincCallback then
81+
val ctx0 = ctx.withProperty(NonLocalClassSymbolsInCurrentUnits, Some(nonLocalClassSymbols))
82+
super.runOn(units)(using ctx0)
83+
else
84+
units // still run the phase for the side effects (writing TASTy files to -Yearly-tasty-output)
85+
sigWriter.foreach(writeSigFiles(units0, _))
86+
if doZincCallback then
87+
ctx.withIncCallback(recordNonLocalClasses(nonLocalClassSymbols, _))
88+
if ctx.settings.YjavaTasty.value then
89+
units0.filterNot(_.typedAsJava) // remove java sources, this is the terminal phase when `-Yjava-tasty` is set
90+
else
91+
units0
7692
end runOn
7793

94+
// Why we only write to early output in the first run?
95+
// ===================================================
96+
// TL;DR the point of pipeline compilation is to start downstream projects early,
97+
// so we don't want to wait for suspended units to be compiled.
98+
//
99+
// But why is it safe to ignore suspended units?
100+
// If this project contains a transparent macro that is called in the same project,
101+
// the compilation unit of that call will be suspended (if the macro implementation
102+
// is also in this project), causing a second run.
103+
// However before we do that run, we will have already requested sbt to begin
104+
// early downstream compilation. This means that the suspended definitions will not
105+
// be visible in *early* downstream compilation.
106+
//
107+
// However, sbt will by default prevent downstream compilation happening in this scenario,
108+
// due to the existence of macro definitions. So we are protected from failure if user tries
109+
// to use the suspended definitions.
110+
//
111+
// Additionally, it is recommended for the user to move macro implementations to another project
112+
// if they want to force early output. In this scenario the suspensions will no longer occur, so now
113+
// they will become visible in the early-output.
114+
//
115+
// See `sbt-test/pipelining/pipelining-scala-macro` and `sbt-test/pipelining/pipelining-scala-macro-force`
116+
// for examples of this in action.
117+
//
118+
// Therefore we only need to write to early output in the first run. We also provide the option
119+
// to diagnose suspensions with the `-Yno-suspended-units` flag.
120+
private def writeSigFiles(units: List[CompilationUnit], writer: Pickler.EarlyFileWriter)(using Context): Unit = {
121+
try
122+
for
123+
unit <- units
124+
(cls, pickled) <- unit.pickled
125+
if cls.isDefinedInCurrentRun
126+
do
127+
val internalName =
128+
if cls.is(Module) then cls.binaryClassName.stripSuffix(str.MODULE_SUFFIX).nn
129+
else cls.binaryClassName
130+
val _ = writer.writeTasty(internalName, pickled())
131+
finally
132+
writer.close()
133+
if ctx.settings.verbose.value then
134+
report.echo("[sig files written]")
135+
end try
136+
}
137+
78138
private def recordNonLocalClasses(nonLocalClassSymbols: mutable.HashSet[Symbol], cb: interfaces.IncrementalCallback)(using Context): Unit =
79139
for cls <- nonLocalClassSymbols do
80140
val sourceFile = cls.source

Diff for: compiler/src/dotty/tools/dotc/transform/Pickler.scala

+8-46
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ class Pickler extends Phase {
5656

5757
// No need to repickle trees coming from TASTY
5858
override def isRunnable(using Context): Boolean =
59-
super.isRunnable && (!ctx.settings.fromTasty.value || ctx.settings.YjavaTasty.value)
59+
super.isRunnable && !ctx.settings.fromTasty.value
6060

6161
// when `-Yjava-tasty` is set we actually want to run this phase on Java sources
6262
override def skipIfJava(using Context): Boolean = false
@@ -94,9 +94,7 @@ class Pickler extends Phase {
9494

9595
private val executor = Executor[Array[Byte]]()
9696

97-
private def useExecutor(using Context) =
98-
Pickler.ParallelPickling && !ctx.settings.YtestPickler.value &&
99-
!ctx.settings.YjavaTasty.value // disable parallel pickling when `-Yjava-tasty` is set (internal testing only)
97+
private def useExecutor(using Context) = Pickler.ParallelPickling && !ctx.settings.YtestPickler.value
10098

10199
private def printerContext(isOutline: Boolean)(using Context): Context =
102100
if isOutline then ctx.fresh.setPrinterFn(OutlinePrinter(_))
@@ -196,22 +194,13 @@ class Pickler extends Phase {
196194
}
197195

198196
override def runOn(units: List[CompilationUnit])(using Context): List[CompilationUnit] = {
199-
val sigWriter: Option[Pickler.EarlyFileWriter] = ctx.settings.YjavaTastyOutput.value match
200-
case jar: JarArchive if jar.exists =>
201-
Some(Pickler.EarlyFileWriter(jar))
202-
case _ =>
203-
None
204-
val units0 =
205-
if ctx.settings.fromTasty.value then
206-
// we still run the phase for the side effect of writing the pipeline tasty files
207-
units
197+
val result =
198+
if useExecutor then
199+
executor.start()
200+
try super.runOn(units)
201+
finally executor.close()
208202
else
209-
if useExecutor then
210-
executor.start()
211-
try super.runOn(units)
212-
finally executor.close()
213-
else
214-
super.runOn(units)
203+
super.runOn(units)
215204
if ctx.settings.YtestPickler.value then
216205
val ctx2 = ctx.fresh
217206
.setSetting(ctx.settings.YreadComments, true)
@@ -222,36 +211,9 @@ class Pickler extends Phase {
222211
.setReporter(new ThrowingReporter(ctx.reporter))
223212
.addMode(Mode.ReadPositions)
224213
)
225-
val result =
226-
if ctx.settings.YjavaTasty.value then
227-
sigWriter.foreach(writeJavaSigFiles(units0, _))
228-
units0.filterNot(_.typedAsJava) // remove java sources, this is the terminal phase when `-Yjava-tasty` is set
229-
else
230-
units0
231214
result
232215
}
233216

234-
private def writeJavaSigFiles(units: List[CompilationUnit], writer: Pickler.EarlyFileWriter)(using Context): Unit = {
235-
var count = 0
236-
try
237-
for
238-
unit <- units if unit.typedAsJava
239-
(cls, pickled) <- unit.pickled
240-
if cls.isDefinedInCurrentRun
241-
do
242-
val binaryClassName = cls.binaryClassName
243-
val internalName =
244-
if (cls.is(Module)) binaryClassName.stripSuffix(str.MODULE_SUFFIX).nn
245-
else binaryClassName
246-
val _ = writer.writeTasty(internalName, pickled())
247-
count += 1
248-
finally
249-
writer.close()
250-
if ctx.settings.verbose.value then
251-
report.echo(s"[$count java sig files written]")
252-
end try
253-
}
254-
255217
private def testUnpickler(using Context): Unit =
256218
pickling.println(i"testing unpickler at run ${ctx.runId}")
257219
ctx.initialize()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package a
2+
3+
import scala.quoted.*
4+
5+
object A {
6+
inline def power(x: Double, inline n: Int): Double =
7+
inline if (n == 0) 1.0
8+
else inline if (n % 2 == 1) x * power(x, n - 1)
9+
else power(x * x, n / 2)
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package b
2+
3+
import a.A
4+
5+
object B {
6+
@main def run =
7+
assert(A.power(2.0, 2) == 4.0)
8+
assert(A.power(2.0, 3) == 8.0)
9+
assert(A.power(2.0, 4) == 16.0)
10+
}

0 commit comments

Comments
 (0)