Skip to content

Commit bb61b27

Browse files
committed
Finalize work on @SInCE and -scala-release:
* Add missing @SInCE annotations to definitions * Add missing checks for @SInCE * Add more tests
1 parent f3b85f1 commit bb61b27

File tree

29 files changed

+219
-113
lines changed

29 files changed

+219
-113
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package dotty.tools.dotc.config
2+
3+
import dotty.tools.tasty.TastyVersion
4+
5+
enum ScalaRelease(val majorVersion: Int, val minorVersion: Int) extends Ordered[ScalaRelease]:
6+
case Release3_0 extends ScalaRelease(3, 0)
7+
case Release3_1 extends ScalaRelease(3, 1)
8+
9+
def show = s"$majorVersion.$minorVersion"
10+
11+
def compare(that: ScalaRelease) =
12+
val ord = summon[Ordering[(Int, Int)]]
13+
ord.compare((majorVersion, minorVersion), (that.majorVersion, that.minorVersion))
14+
15+
object ScalaRelease:
16+
def latest = Release3_1
17+
18+
def parse(name: String) = name match
19+
case "3.0" => Some(Release3_0)
20+
case "3.1" => Some(Release3_1)
21+
case _ => None

compiler/src/dotty/tools/dotc/config/ScalaSettings.scala

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ object ScalaSettings:
2626
(minTargetVersion to maxVersion).toList.map(_.toString)
2727
else List(minTargetVersion).map(_.toString)
2828

29+
def supportedScalaReleaseVersions: List[String] =
30+
ScalaRelease.values.toList.map(_.show)
31+
2932
def defaultClasspath: String = sys.env.getOrElse("CLASSPATH", ".")
3033

3134
def defaultPageWidth: Int = {
@@ -101,7 +104,7 @@ trait CommonScalaSettings:
101104
val silentWarnings: Setting[Boolean] = BooleanSetting("-nowarn", "Silence all warnings.", aliases = List("--no-warnings"))
102105

103106
val release: Setting[String] = ChoiceSetting("-release", "release", "Compile code with classes specific to the given version of the Java platform available on the classpath and emit bytecode for this version.", ScalaSettings.supportedReleaseVersions, "", aliases = List("--release"))
104-
val scalaRelease: Setting[ScalaVersion] = VersionSetting("-scala-release", "Emit TASTy files that can be consumed by specified version of the compiler.")
107+
val scalaRelease: Setting[String] = ChoiceSetting("-scala-release", "release", "Emit TASTy files that can be consumed by specified version of the compiler.", ScalaSettings.supportedScalaReleaseVersions, "", aliases = List("--scala-release"))
105108
val deprecation: Setting[Boolean] = BooleanSetting("-deprecation", "Emit warning and location for usages of deprecated APIs.", aliases = List("--deprecation"))
106109
val feature: Setting[Boolean] = BooleanSetting("-feature", "Emit warning and location for usages of features that should be imported explicitly.", aliases = List("--feature"))
107110
val explain: Setting[Boolean] = BooleanSetting("-explain", "Explain errors in more detail.", aliases = List("--explain"))

compiler/src/dotty/tools/dotc/core/Contexts.scala

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import io.{AbstractFile, NoAbstractFile, PlainFile, Path}
2424
import scala.io.Codec
2525
import collection.mutable
2626
import printing._
27-
import config.{JavaPlatform, SJSPlatform, Platform, ScalaSettings}
27+
import config.{JavaPlatform, SJSPlatform, Platform, ScalaSettings, ScalaRelease}
2828
import classfile.ReusableDataReader
2929
import StdNames.nme
3030

@@ -38,8 +38,8 @@ import xsbti.AnalysisCallback
3838
import plugins._
3939
import java.util.concurrent.atomic.AtomicInteger
4040
import java.nio.file.InvalidPathException
41-
import dotty.tools.tasty.TastyFormat
42-
import dotty.tools.dotc.config.{ NoScalaVersion, SpecificScalaVersion, AnyScalaVersion }
41+
import dotty.tools.tasty.{ TastyFormat, TastyVersion }
42+
import dotty.tools.dotc.config.{ NoScalaVersion, SpecificScalaVersion, AnyScalaVersion, ScalaBuild }
4343

4444
object Contexts {
4545

@@ -484,13 +484,20 @@ object Contexts {
484484
def importContext(imp: Import[?], sym: Symbol): FreshContext =
485485
fresh.setImportInfo(ImportInfo(sym, imp.selectors, imp.expr))
486486

487-
def tastyVersion: (Int, Int, Int) =
488-
base.settings.scalaRelease.value match
489-
case NoScalaVersion =>
490-
import TastyFormat.*
491-
(MajorVersion, MinorVersion, ExperimentalVersion)
492-
case SpecificScalaVersion(maj, min, _, _) => (maj.toInt + 25, min.toInt, 0)
493-
case AnyScalaVersion => (28, 0, 0) // 3.0
487+
def scalaRelease: ScalaRelease =
488+
val releaseName = base.settings.scalaRelease.value
489+
if releaseName.nonEmpty then ScalaRelease.parse(releaseName).get else ScalaRelease.latest
490+
491+
def tastyVersion: TastyVersion =
492+
import math.Ordered.orderingToOrdered
493+
val latestRelease = ScalaRelease.latest
494+
val specifiedRelease = scalaRelease
495+
if ((specifiedRelease.majorVersion, specifiedRelease.minorVersion) < (latestRelease.majorVersion, latestRelease.majorVersion)) then
496+
// This is needed to make -scala-release a no-op when set to the latest release for unstable versions of the compiler
497+
// (which might have the tasty format version numbers set to higher values before they're decreased during a release)
498+
TastyVersion.fromStableScalaRelease(specifiedRelease.majorVersion, specifiedRelease.minorVersion)
499+
else
500+
TastyVersion.compilerVersion
494501

495502
/** Is the debug option set? */
496503
def debug: Boolean = base.settings.Ydebug.value

compiler/src/dotty/tools/dotc/core/classfile/ClassfileParser.scala

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package dotc
33
package core
44
package classfile
55

6-
import dotty.tools.tasty.{ TastyReader, TastyHeaderUnpickler }
6+
import dotty.tools.tasty.{ TastyFormat, TastyReader, TastyHeaderUnpickler, TastyVersion }
77

88
import Contexts._, Symbols._, Types._, Names._, StdNames._, NameOps._, Scopes._, Decorators._
99
import SymDenotations._, unpickleScala2.Scala2Unpickler._, Constants._, Annotations._, util.Spans._
@@ -884,7 +884,7 @@ class ClassfileParser(
884884
}
885885

886886
def unpickleTASTY(bytes: Array[Byte]): Some[Embedded] = {
887-
val unpickler = new tasty.DottyUnpickler(bytes)
887+
val unpickler = new tasty.DottyUnpickler(bytes, ctx.tastyVersion)
888888
unpickler.enter(roots = Set(classRoot, moduleRoot, moduleRoot.sourceModule))(using ctx.withSource(util.NoSource))
889889
Some(unpickler)
890890
}
@@ -950,9 +950,23 @@ class ClassfileParser(
950950
if (tastyBytes.nonEmpty) {
951951
val reader = new TastyReader(bytes, 0, 16)
952952
val expectedUUID = new UUID(reader.readUncompressedLong(), reader.readUncompressedLong())
953-
val tastyUUID = new TastyHeaderUnpickler(tastyBytes).readHeader()
953+
val tastyHeader = new TastyHeaderUnpickler(tastyBytes).readFullHeader()
954+
val fileTastyVersion = TastyVersion(tastyHeader.majorVersion, tastyHeader.minorVersion, tastyHeader.experimentalVersion)
955+
val tastyUUID = tastyHeader.uuid
954956
if (expectedUUID != tastyUUID)
955957
report.warning(s"$classfile is out of sync with its TASTy file. Loaded TASTy file. Try cleaning the project to fix this issue", NoSourcePosition)
958+
959+
val tastyFilePath = classfile.path.stripSuffix(".class") + ".tasty"
960+
val isTastyCompatible =
961+
TastyFormat.isVersionCompatible(fileVersion = fileTastyVersion, compilerVersion = ctx.tastyVersion) ||
962+
classRoot.symbol.showFullName.startsWith("scala.") // References to stdlib are considered safe because we check the values of @since annotations
963+
964+
if !isTastyCompatible then
965+
report.error(s"""The class ${classRoot.symbol.showFullName} cannot be loaded from file ${tastyFilePath} because its TASTy format version is too high
966+
|highest allowed: ${ctx.tastyVersion.show}
967+
|found: ${fileTastyVersion.show}
968+
""".stripMargin)
969+
956970
return unpickleTASTY(tastyBytes)
957971
}
958972
}

compiler/src/dotty/tools/dotc/core/tasty/DottyUnpickler.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import classfile.ClassfileParser
1010
import Names.SimpleName
1111
import TreeUnpickler.UnpickleMode
1212

13-
import dotty.tools.tasty.TastyReader
13+
import dotty.tools.tasty.{ TastyReader, TastyVersion }
1414
import dotty.tools.tasty.TastyFormat.{ASTsSection, PositionsSection, CommentsSection}
1515

1616
object DottyUnpickler {
@@ -39,7 +39,7 @@ object DottyUnpickler {
3939
* @param bytes the bytearray containing the Tasty file from which we unpickle
4040
* @param mode the tasty file contains package (TopLevel), an expression (Term) or a type (TypeTree)
4141
*/
42-
class DottyUnpickler(bytes: Array[Byte], mode: UnpickleMode = UnpickleMode.TopLevel) extends ClassfileParser.Embedded with tpd.TreeProvider {
42+
class DottyUnpickler(bytes: Array[Byte], maximalTastyVersion: TastyVersion, mode: UnpickleMode = UnpickleMode.TopLevel) extends ClassfileParser.Embedded with tpd.TreeProvider {
4343
import tpd._
4444
import DottyUnpickler._
4545

compiler/src/dotty/tools/dotc/core/tasty/TastyPickler.scala

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,14 @@ class TastyPickler(val rootCls: ClassSymbol) {
3636
def lengthWithLength(buf: TastyBuffer) =
3737
buf.length + natSize(buf.length)
3838

39-
val (majorVersion, minorVersion, experimentalVersion) = ctx.tastyVersion
40-
4139
nameBuffer.assemble()
4240
sections.foreach(_._2.assemble())
4341

4442
val nameBufferHash = TastyHash.pjwHash64(nameBuffer.bytes)
4543
val treeSectionHash +: otherSectionHashes = sections.map(x => TastyHash.pjwHash64(x._2.bytes))
4644

45+
val tastyVersion = ctx.tastyVersion
46+
4747
// Hash of name table and tree
4848
val uuidLow: Long = nameBufferHash ^ treeSectionHash
4949
// Hash of positions, comments and any additional section
@@ -52,9 +52,9 @@ class TastyPickler(val rootCls: ClassSymbol) {
5252
val headerBuffer = {
5353
val buf = new TastyBuffer(header.length + TastyPickler.versionStringBytes.length + 32)
5454
for (ch <- header) buf.writeByte(ch.toByte)
55-
buf.writeNat(majorVersion)
56-
buf.writeNat(minorVersion)
57-
buf.writeNat(experimentalVersion)
55+
buf.writeNat(tastyVersion.major)
56+
buf.writeNat(tastyVersion.minor)
57+
buf.writeNat(tastyVersion.experimental)
5858
buf.writeNat(TastyPickler.versionStringBytes.length)
5959
buf.writeBytes(TastyPickler.versionStringBytes, TastyPickler.versionStringBytes.length)
6060
buf.writeUncompressedLong(uuidLow)

compiler/src/dotty/tools/dotc/quoted/PickledQuotes.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ object PickledQuotes {
198198
quotePickling.println(s"**** unpickling quote from TASTY\n${TastyPrinter.showContents(bytes, ctx.settings.color.value == "never")}")
199199

200200
val mode = if (isType) UnpickleMode.TypeTree else UnpickleMode.Term
201-
val unpickler = new DottyUnpickler(bytes, mode)
201+
val unpickler = new DottyUnpickler(bytes, ctx.tastyVersion, mode)
202202
unpickler.enter(Set.empty)
203203

204204
val tree = unpickler.tree

compiler/src/dotty/tools/dotc/transform/Pickler.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ class Pickler extends Phase {
125125
ctx.initialize()
126126
val unpicklers =
127127
for ((cls, pickler) <- picklers) yield {
128-
val unpickler = new DottyUnpickler(pickler.assembleParts())
128+
val unpickler = new DottyUnpickler(pickler.assembleParts(), ctx.tastyVersion)
129129
unpickler.enter(roots = Set.empty)
130130
cls -> unpickler
131131
}

compiler/src/dotty/tools/dotc/typer/RefChecks.scala

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import ast._
1515
import MegaPhase._
1616
import config.Printers.{checks, noPrinter}
1717
import scala.util.{Try, Failure, Success}
18-
import config.{ScalaVersion, NoScalaVersion}
18+
import config.{ScalaVersion, NoScalaVersion, ScalaRelease}
1919
import Decorators._
2020
import OverridingPairs.isOverridingPair
2121
import typer.ErrorReporting._
@@ -976,14 +976,14 @@ object RefChecks {
976976
annot <- sym.getAnnotation(defn.SinceAnnot)
977977
version <- annot.argumentConstantString(0)
978978
do
979-
val releaseVersion = ctx.settings.scalaRelease.value
980-
ScalaVersion.parse(version) match
981-
case Success(symVersion) if symVersion > ctx.settings.scalaRelease.value =>
979+
val releaseVersion = ctx.scalaRelease
980+
ScalaRelease.parse(version) match
981+
case Some(symVersion) if symVersion > releaseVersion =>
982982
report.error(
983-
i"$sym was added in Scala $version, therefore it cannot be used in the code targeting Scala ${releaseVersion.unparse}",
983+
i"$sym was added in Scala $version, therefore it cannot be used in the code targeting Scala ${releaseVersion.show}",
984984
pos)
985-
case Failure(ex) =>
986-
report.warning(i"$sym has an unparsable version number: ${ex.getMessage}", pos)
985+
case None =>
986+
report.warning(i"$sym has an unparsable release name: '${version}'", pos)
987987
case _ =>
988988

989989
private def checkSinceAnnotInSignature(sym: Symbol, pos: SrcPos)(using Context) =
@@ -1320,6 +1320,7 @@ class RefChecks extends MiniPhase { thisPhase =>
13201320
checkImplicitNotFoundAnnotation.template(cls.classDenot)
13211321
checkExperimentalInheritance(cls)
13221322
checkExperimentalAnnots(cls)
1323+
checkSinceAnnot(cls, cls.srcPos)
13231324
tree
13241325
}
13251326
catch {
@@ -1371,9 +1372,11 @@ class RefChecks extends MiniPhase { thisPhase =>
13711372
case TypeRef(_, sym: Symbol) =>
13721373
checkDeprecated(sym, tree.srcPos)
13731374
checkExperimental(sym, tree.srcPos)
1375+
checkSinceAnnot(sym, tree.srcPos)
13741376
case TermRef(_, sym: Symbol) =>
13751377
checkDeprecated(sym, tree.srcPos)
13761378
checkExperimental(sym, tree.srcPos)
1379+
checkSinceAnnot(sym, tree.srcPos)
13771380
case _ =>
13781381
}
13791382
tree

compiler/test/dotty/tools/dotc/core/tasty/CommentPicklingTest.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ class CommentPicklingTest {
114114
implicit val ctx: Context = setup(args, initCtx).map(_._2).getOrElse(initCtx)
115115
ctx.initialize()
116116
val trees = files.flatMap { f =>
117-
val unpickler = new DottyUnpickler(f.toByteArray())
117+
val unpickler = new DottyUnpickler(f.toByteArray(), ctx.tastyVersion)
118118
unpickler.enter(roots = Set.empty)
119119
unpickler.rootTrees(using ctx)
120120
}

compiler/test/dotty/tools/vulpix/ParallelTesting.scala

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ trait ParallelTesting extends RunnerOrchestration { self =>
128128
}
129129
sb.toString + "\n\n"
130130
}
131-
case self: SeparateCompilationSource => { // TODO: this is incorrect when using other versions of compiler
131+
case self: SeparateCompilationSource => { // TODO: this won't work when using other versions of compiler
132132
val command = sb.toString
133133
val fsb = new StringBuilder(command)
134134
self.compilationGroups.foreach { (_, files) =>
@@ -174,25 +174,25 @@ trait ParallelTesting extends RunnerOrchestration { self =>
174174
flags: TestFlags,
175175
outDir: JFile
176176
) extends TestSource {
177-
case class Group(ordinal: Int, compiler: String, target: String)
177+
case class Group(ordinal: Int, compiler: String, release: String)
178178

179179
lazy val compilationGroups: List[(Group, Array[JFile])] =
180-
val Target = """t([\d\.]+)""".r
181-
val Compiler = """v([\d\.]+)""".r
180+
val Release = """r([\d\.]+)""".r
181+
val Compiler = """c([\d\.]+)""".r
182182
val Ordinal = """(\d+)""".r
183183
def groupFor(file: JFile): Group =
184-
val annotPart = file.getName.dropWhile(_ != '_').stripSuffix(".scala").stripSuffix(".java")
185-
val annots = annotPart.split("_")
186-
val ordinal = annots.collectFirst { case Ordinal(n) => n.toInt }.getOrElse(Int.MinValue)
187-
val target = annots.collectFirst { case Target(t) => t }.getOrElse("")
188-
val compiler = annots.collectFirst { case Compiler(c) => c}.getOrElse("")
189-
Group(ordinal, compiler, target)
184+
val groupSuffix = file.getName.dropWhile(_ != '_').stripSuffix(".scala").stripSuffix(".java")
185+
val groupSuffixParts = groupSuffix.split("_")
186+
val ordinal = groupSuffixParts.collectFirst { case Ordinal(n) => n.toInt }.getOrElse(Int.MinValue)
187+
val release = groupSuffixParts.collectFirst { case Release(r) => r }.getOrElse("")
188+
val compiler = groupSuffixParts.collectFirst { case Compiler(c) => c }.getOrElse("")
189+
Group(ordinal, compiler, release)
190190

191191
dir.listFiles
192192
.filter(isSourceFile)
193193
.groupBy(groupFor)
194194
.toList
195-
.sortBy { (g, _) => (g.ordinal, g.compiler, g.target) }
195+
.sortBy { (g, _) => (g.ordinal, g.compiler, g.release) }
196196
.map { (g, f) => (g, f.sorted) }
197197

198198
def sourceFiles = compilationGroups.map(_._2).flatten.toArray
@@ -215,7 +215,7 @@ trait ParallelTesting extends RunnerOrchestration { self =>
215215

216216
case testSource @ SeparateCompilationSource(_, dir, flags, outDir) =>
217217
testSource.compilationGroups.map { (group, files) =>
218-
val flags1 = if group.target.isEmpty then flags else flags.and(s"-scala-release:${group.target}")
218+
val flags1 = if group.release.isEmpty then flags else flags.and(s"-scala-release:${group.release}")
219219
if group.compiler.isEmpty then
220220
compile(files, flags1, suppressErrors, outDir)
221221
else
@@ -509,15 +509,15 @@ trait ParallelTesting extends RunnerOrchestration { self =>
509509

510510
def substituteClasspath(old: String): String =
511511
old.split(JFile.pathSeparator).map { o =>
512-
if JFile(o) == JFile(Properties.dottyLibrary) then s"$compilerDir/lib/scala3-library_3-${trueVersions(compiler)}.jar"
512+
if JFile(o) == JFile(Properties.dottyLibrary) then s"$compilerDir/lib/scala3-library_3-${patchVersions(compiler)}.jar"
513513
else o
514514
}.mkString(JFile.pathSeparator)
515515

516516
val flags1 = flags.copy(defaultClassPath = substituteClasspath(flags.defaultClassPath))
517517
.withClasspath(targetDir.getPath)
518518
.and("-d", targetDir.getPath)
519519

520-
val reporter = TestReporter.reporter(realStdout, ERROR) // TODO: do some reporting
520+
val reporter = TestReporter.reporter(realStdout, ERROR)
521521

522522
val command = Array(compilerDir + "/bin/scalac") ++ flags1.all ++ files.map(_.getPath)
523523
val process = Runtime.getRuntime.exec(command)
@@ -1401,24 +1401,27 @@ object ParallelTesting {
14011401
f.getName.endsWith(".tasty")
14021402

14031403
def getCompiler(version: String): JFile =
1404-
val patch = trueVersions(version)
1404+
val patch = patchVersions(version)
14051405
val dir = cache.resolve(s"scala3-${patch}").toFile
1406-
if dir.exists then
1407-
dir
1408-
else
1409-
import scala.sys.process._
1410-
val zipPath = cache.resolve(s"scala3-$patch.zip")
1411-
(URL(s"https://github.com/lampepfl/dotty/releases/download/$patch/scala3-$patch.zip") #>> zipPath.toFile #&& s"unzip $zipPath -d $cache").!!
1412-
dir
1406+
synchronized {
1407+
if dir.exists then
1408+
dir
1409+
else
1410+
import scala.sys.process._
1411+
val zipPath = cache.resolve(s"scala3-$patch.zip")
1412+
val compilerDownloadUrl = s"https://github.com/lampepfl/dotty/releases/download/$patch/scala3-$patch.zip"
1413+
(URL(compilerDownloadUrl) #>> zipPath.toFile #&& s"unzip $zipPath -d $cache").!!
1414+
dir
1415+
}
14131416

14141417

1415-
val trueVersions = Map(
1418+
val patchVersions = Map(
14161419
"3.0" -> "3.0.2",
14171420
"3.1" -> "3.1.0"
14181421
)
14191422

14201423
private lazy val cache =
14211424
val dir = Files.createTempDirectory("dotty.tests")
1422-
// dir.toFile.deleteOnExit()
1425+
dir.toFile.deleteOnExit()
14231426
dir
14241427
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
package scala.annotation
22

33
/** An annotation that is used to mark symbols added to the stdlib after 3.0 release */
4-
private[scala] class since(scalaRelease: String) extends scala.annotation.StaticAnnotation
4+
private[scala] class since(scalaRelease: String) extends scala.annotation.StaticAnnotation

tasty/src/dotty/tools/tasty/TastyFormat.scala

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -338,16 +338,12 @@ object TastyFormat {
338338
* @syntax markdown
339339
*/
340340
def isVersionCompatible(
341-
fileMajor: Int,
342-
fileMinor: Int,
343-
fileExperimental: Int,
344-
compilerMajor: Int,
345-
compilerMinor: Int,
346-
compilerExperimental: Int
341+
fileVersion: TastyVersion,
342+
compilerVersion: TastyVersion
347343
): Boolean = (
348-
fileMajor == compilerMajor &&
349-
( fileMinor == compilerMinor && fileExperimental == compilerExperimental // full equality
350-
|| fileMinor < compilerMinor && fileExperimental == 0 // stable backwards compatibility
344+
fileVersion.major == compilerVersion.major &&
345+
( fileVersion.minor == compilerVersion.minor && fileVersion.experimental == compilerVersion.experimental // full equality
346+
|| fileVersion.minor < compilerVersion.minor && fileVersion.experimental == 0 // stable backwards compatibility
351347
)
352348
)
353349

0 commit comments

Comments
 (0)