Skip to content

Scaladoc Support for Capture & Separation Checking (Staging) #23607

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

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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
4 changes: 4 additions & 0 deletions project/ScaladocGeneration.scala
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ object ScaladocGeneration {
def key: String = "-dynamic-side-menu"
}

case class SuppressCC(value: Boolean) extends Arg[Boolean] {
def key: String = "-suppressCC"
}

import _root_.scala.reflect._

trait GenerationConfig {
Expand Down
2 changes: 2 additions & 0 deletions scaladoc/src/dotty/tools/scaladoc/Scaladoc.scala
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ object Scaladoc:
defaultTemplate: Option[String] = None,
quickLinks: List[QuickLink] = List.empty,
dynamicSideMenu: Boolean = false,
suppressCC: Boolean = false, // suppress rendering anything related to experimental capture checking
)

def run(args: Array[String], rootContext: CompilerContext): Reporter =
Expand Down Expand Up @@ -231,6 +232,7 @@ object Scaladoc:
defaultTemplate.nonDefault,
quickLinksParsed,
dynamicSideMenu.get,
suppressCC.get,
)
(Some(docArgs), newContext)
}
Expand Down
5 changes: 4 additions & 1 deletion scaladoc/src/dotty/tools/scaladoc/ScaladocSettings.scala
Original file line number Diff line number Diff line change
Expand Up @@ -144,5 +144,8 @@ class ScaladocSettings extends SettingGroup with AllScalaSettings:
val dynamicSideMenu: Setting[Boolean] =
BooleanSetting(RootSetting, "dynamic-side-menu", "Generate side menu via JS instead of embedding it in every html file", false)

val suppressCC: Setting[Boolean] =
BooleanSetting(RootSetting, "suppressCC", "Suppress rendering anything related to experimental capture checking", false)

def scaladocSpecificSettings: Set[Setting[?]] =
Set(sourceLinks, legacySourceLink, syntax, revision, externalDocumentationMappings, socialLinks, skipById, skipByRegex, deprecatedSkipPackages, docRootContent, snippetCompiler, generateInkuire, defaultTemplate, scastieConfiguration, quickLinks, dynamicSideMenu)
Set(sourceLinks, legacySourceLink, syntax, revision, externalDocumentationMappings, socialLinks, skipById, skipByRegex, deprecatedSkipPackages, docRootContent, snippetCompiler, generateInkuire, defaultTemplate, scastieConfiguration, quickLinks, dynamicSideMenu, suppressCC)
6 changes: 4 additions & 2 deletions scaladoc/src/dotty/tools/scaladoc/api.scala
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ enum Modifier(val name: String, val prefix: Boolean):
case Transparent extends Modifier("transparent", true)
case Infix extends Modifier("infix", true)
case AbsOverride extends Modifier("abstract override", true)
case Update extends Modifier("update", true)

case class ExtensionTarget(name: String, typeParams: Seq[TypeParameter], argsLists: Seq[TermParameterList], signature: Signature, dri: DRI, position: Long)
case class ImplicitConversion(from: DRI, to: DRI)
Expand All @@ -69,7 +70,7 @@ enum Kind(val name: String):
case Var extends Kind("var")
case Val extends Kind("val")
case Exported(base: Kind) extends Kind("export")
case Type(concreate: Boolean, opaque: Boolean, typeParams: Seq[TypeParameter])
case Type(concreate: Boolean, opaque: Boolean, typeParams: Seq[TypeParameter], isCaptureVar: Boolean = false)
extends Kind("type") // should we handle opaque as modifier?
case Given(kind: Def | Class | Val.type, as: Option[Signature], conversion: Option[ImplicitConversion])
extends Kind("given") with ImplicitConversionProvider
Expand Down Expand Up @@ -120,7 +121,8 @@ case class TypeParameter(
variance: "" | "+" | "-",
name: String,
dri: DRI,
signature: Signature
signature: Signature,
isCaptureVar: Boolean = false // under capture checking
)

case class Link(name: String, dri: DRI)
Expand Down
225 changes: 225 additions & 0 deletions scaladoc/src/dotty/tools/scaladoc/cc/CaptureOps.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package dotty.tools.scaladoc

package cc

import scala.quoted._

object CaptureDefs:
// these should become part of the reflect API in the distant future
def retains(using qctx: Quotes) =
qctx.reflect.Symbol.requiredClass("scala.annotation.retains")
def retainsCap(using qctx: Quotes) =
qctx.reflect.Symbol.requiredClass("scala.annotation.retainsCap")
def retainsByName(using qctx: Quotes) =
qctx.reflect.Symbol.requiredClass("scala.annotation.retainsByName")
def CapsModule(using qctx: Quotes) =
qctx.reflect.Symbol.requiredPackage("scala.caps")
def captureRoot(using qctx: Quotes) =
qctx.reflect.Symbol.requiredPackage("scala.caps.cap")
def Caps_Capability(using qctx: Quotes) =
qctx.reflect.Symbol.requiredClass("scala.caps.Capability")
def Caps_CapSet(using qctx: Quotes) =
qctx.reflect.Symbol.requiredClass("scala.caps.CapSet")
def Caps_Mutable(using qctx: Quotes) =
qctx.reflect.Symbol.requiredClass("scala.caps.Mutable")
def Caps_SharedCapability(using qctx: Quotes) =
qctx.reflect.Symbol.requiredClass("scala.caps.SharedCapability")
def UseAnnot(using qctx: Quotes) =
qctx.reflect.Symbol.requiredClass("scala.caps.use")
def ConsumeAnnot(using qctx: Quotes) =
qctx.reflect.Symbol.requiredClass("scala.caps.consume")
def ReachCapabilityAnnot(using qctx: Quotes) =
qctx.reflect.Symbol.requiredClass("scala.annotation.internal.reachCapability")
def RootCapabilityAnnot(using qctx: Quotes) =
qctx.reflect.Symbol.requiredClass("scala.caps.internal.rootCapability")
def ReadOnlyCapabilityAnnot(using qctx: Quotes) =
qctx.reflect.Symbol.requiredClass("scala.annotation.internal.readOnlyCapability")
def RequiresCapabilityAnnot(using qctx: Quotes) =
qctx.reflect.Symbol.requiredClass("scala.annotation.internal.requiresCapability")
def OnlyCapabilityAnnot(using qctx: Quotes) =
qctx.reflect.Symbol.requiredClass("scala.annotation.internal.onlyCapability")

def LanguageExperimental(using qctx: Quotes) =
qctx.reflect.Symbol.requiredPackage("scala.language.experimental")

def ImpureFunction1(using qctx: Quotes) =
qctx.reflect.Symbol.requiredClass("scala.ImpureFunction1")

def ImpureContextFunction1(using qctx: Quotes) =
qctx.reflect.Symbol.requiredClass("scala.ImpureContextFunction1")

def Function1(using qctx: Quotes) =
qctx.reflect.Symbol.requiredClass("scala.Function1")

def ContextFunction1(using qctx: Quotes) =
qctx.reflect.Symbol.requiredClass("scala.ContextFunction1")

val useAnnotFullName: String = "scala.caps.use.<init>"
val consumeAnnotFullName: String = "scala.caps.consume.<init>"
val ccImportSelector = "captureChecking"
end CaptureDefs

extension (using qctx: Quotes)(ann: qctx.reflect.Symbol)
/** This symbol is one of `retains` or `retainsCap` */
def isRetains: Boolean =
ann == CaptureDefs.retains || ann == CaptureDefs.retainsCap

/** This symbol is one of `retains`, `retainsCap`, or `retainsByName` */
def isRetainsLike: Boolean =
ann.isRetains || ann == CaptureDefs.retainsByName

def isReachCapabilityAnnot: Boolean =
ann == CaptureDefs.ReachCapabilityAnnot

def isReadOnlyCapabilityAnnot: Boolean =
ann == CaptureDefs.ReadOnlyCapabilityAnnot

def isOnlyCapabilityAnnot: Boolean =
ann == CaptureDefs.OnlyCapabilityAnnot
end extension

extension (using qctx: Quotes)(tpe: qctx.reflect.TypeRepr) // FIXME clean up and have versions on Symbol for those
def isCaptureRoot: Boolean =
import qctx.reflect.*
tpe match
case TermRef(ThisType(TypeRef(NoPrefix(), "caps")), "cap") => true
case TermRef(TermRef(ThisType(TypeRef(NoPrefix(), "scala")), "caps"), "cap") => true
case TermRef(TermRef(TermRef(TermRef(NoPrefix(), "_root_"), "scala"), "caps"), "cap") => true
case _ => false

// NOTE: There's something horribly broken with Symbols, and we can't rely on tests like .isContextFunctionType either,
// so we do these lame string comparisons instead.
def isImpureFunction1: Boolean = tpe.typeSymbol.fullName == "scala.ImpureFunction1"

def isImpureContextFunction1: Boolean = tpe.typeSymbol.fullName == "scala.ImpureContextFunction1"

def isFunction1: Boolean = tpe.typeSymbol.fullName == "scala.Function1"

def isContextFunction1: Boolean = tpe.typeSymbol.fullName == "scala.ContextFunction1"

def isAnyImpureFunction: Boolean = tpe.typeSymbol.fullName.startsWith("scala.ImpureFunction")

def isAnyImpureContextFunction: Boolean = tpe.typeSymbol.fullName.startsWith("scala.ImpureContextFunction")

def isAnyFunction: Boolean = tpe.typeSymbol.fullName.startsWith("scala.Function")

def isAnyContextFunction: Boolean = tpe.typeSymbol.fullName.startsWith("scala.ContextFunction")

def isCapSet: Boolean = tpe.typeSymbol == CaptureDefs.Caps_CapSet

def isCapSetPure: Boolean =
tpe.isCapSet && tpe.match
case CapturingType(_, refs) => refs.isEmpty
case _ => true

def isCapSetCap: Boolean =
tpe.isCapSet && tpe.match
case CapturingType(_, List(ref)) => ref.isCaptureRoot
case _ => false

def isPureClass(from: qctx.reflect.ClassDef): Boolean =
import qctx.reflect._
def check(sym: Tree): Boolean = sym match
case ClassDef(name, _, _, Some(ValDef(_, tt, _)), _) => tt.tpe match
case CapturingType(_, refs) => refs.isEmpty
case _ => true
case _ => false

// Horrible hack to basically grab tpe1.asSeenFrom(from)
val tpe1 = from.symbol.typeRef.select(tpe.typeSymbol).simplified
val tpe2 = tpe1.classSymbol.map(_.typeRef).getOrElse(tpe1)

// println(s"${tpe.show} -> (${tpe.typeSymbol} from ${from.symbol}) ${tpe1.show} -> ${tpe2} -> ${tpe2.baseClasses.filter(_.isClassDef)}")
val res = tpe2.baseClasses.exists(c => c.isClassDef && check(c.tree))
// println(s"${tpe.show} is pure class = $res")
res
end extension

extension (using qctx: Quotes)(typedef: qctx.reflect.TypeDef)
def derivesFromCapSet: Boolean =
import qctx.reflect.*
typedef.rhs.match
case t: TypeTree => t.tpe.derivesFrom(CaptureDefs.Caps_CapSet)
case t: TypeBoundsTree => t.tpe.derivesFrom(CaptureDefs.Caps_CapSet)
case _ => false
end extension

/** Matches `import scala.language.experimental.captureChecking` */
object CCImport:
def unapply(using qctx: Quotes)(tree: qctx.reflect.Tree): Boolean =
import qctx.reflect._
tree match
case imprt: Import if imprt.expr.tpe.termSymbol == CaptureDefs.LanguageExperimental =>
imprt.selectors.exists {
case SimpleSelector(s) if s == CaptureDefs.ccImportSelector => true
case _ => false
}
case _ => false
end unapply
end CCImport

object ReachCapability:
def unapply(using qctx: Quotes)(ty: qctx.reflect.TypeRepr): Option[qctx.reflect.TypeRepr] =
import qctx.reflect._
ty match
case AnnotatedType(base, Apply(Select(New(annot), _), Nil)) if annot.symbol.isReachCapabilityAnnot =>
Some(base)
case _ => None
end ReachCapability

object ReadOnlyCapability:
def unapply(using qctx: Quotes)(ty: qctx.reflect.TypeRepr): Option[qctx.reflect.TypeRepr] =
import qctx.reflect._
ty match
case AnnotatedType(base, Apply(Select(New(annot), _), Nil)) if annot.symbol.isReadOnlyCapabilityAnnot =>
Some(base)
case _ => None
end ReadOnlyCapability

object OnlyCapability:
def unapply(using qctx: Quotes)(ty: qctx.reflect.TypeRepr): Option[(qctx.reflect.TypeRepr, qctx.reflect.Symbol)] =
import qctx.reflect._
ty match
case AnnotatedType(base, app @ Apply(TypeApply(Select(New(annot), _), _), Nil)) if annot.tpe.typeSymbol.isOnlyCapabilityAnnot =>
app.tpe.typeArgs.head.classSymbol.match
case Some(clazzsym) => Some((base, clazzsym))
case None => None
case _ => None
end OnlyCapability

/** Decompose capture sets in the union-type-encoding into the sequence of atomic `TypeRepr`s.
* Returns `None` if the type is not a capture set.
*/
def decomposeCaptureRefs(using qctx: Quotes)(typ0: qctx.reflect.TypeRepr): Option[List[qctx.reflect.TypeRepr]] =
import qctx.reflect._
val buffer = collection.mutable.ListBuffer.empty[TypeRepr]
def include(t: TypeRepr): Boolean = { buffer += t; true }
def traverse(typ: TypeRepr): Boolean =
typ match
case t if t.typeSymbol == defn.NothingClass => true
case OrType(t1, t2) => traverse(t1) && traverse(t2)
case t @ ThisType(_) => include(t)
case t @ TermRef(_, _) => include(t)
case t @ ParamRef(_, _) => include(t)
case t @ ReachCapability(_) => include(t)
case t @ ReadOnlyCapability(_) => include(t)
case t @ OnlyCapability(_, _) => include(t)
case t : TypeRef => include(t)
case _ => report.warning(s"Unexpected type tree $typ while trying to extract capture references from $typ0"); false
if traverse(typ0) then Some(buffer.toList) else None
end decomposeCaptureRefs

object CaptureSetType:
def unapply(using qctx: Quotes)(tt: qctx.reflect.TypeTree): Option[List[qctx.reflect.TypeRepr]] = decomposeCaptureRefs(tt.tpe)
end CaptureSetType

object CapturingType:
def unapply(using qctx: Quotes)(typ: qctx.reflect.TypeRepr): Option[(qctx.reflect.TypeRepr, List[qctx.reflect.TypeRepr])] =
import qctx.reflect._
typ match
case AnnotatedType(base, Apply(TypeApply(Select(New(annot), _), List(CaptureSetType(refs))), Nil)) if annot.symbol.isRetainsLike =>
Some((base, refs))
case AnnotatedType(base, Apply(Select(New(annot), _), Nil)) if annot.symbol == CaptureDefs.retainsCap =>
Some((base, List(CaptureDefs.captureRoot.termRef)))
case _ => None
end CapturingType
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ class MemberRenderer(signatureRenderer: SignatureRenderer)(using DocContext) ext
cls := s"documentableName $depStyle",
)

val signature: MemberSignature = signatureProvider.rawSignature(member)()
val ctx = summon[DocContext]
val signature: MemberSignature = signatureProvider.rawSignature(member)(!ctx.args.suppressCC)()
val isSubtype = signature.suffix.exists {
case Keyword(keyword) => keyword.contains("extends")
case _ => false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ trait Resources(using ctx: DocContext) extends Locations, Writer:
val (res, pageName) = page.content match
case m: Member if m.kind != Kind.RootPackage =>
def processMember(member: Member, fqName: List[String]): Seq[(JSON, Seq[String])] =
val signature: MemberSignature = signatureProvider.rawSignature(member)()
val signature: MemberSignature = signatureProvider.rawSignature(member)(!ctx.args.suppressCC)()
val sig = Signature(Plain(member.name)) ++ signature.suffix
val descr = if member.kind == Kind.Package then "" else fqName.mkString(".")
val extraDescr = member.docs.map(d => docPartRenderPlain(d.body)).getOrElse("")
Expand Down
9 changes: 7 additions & 2 deletions scaladoc/src/dotty/tools/scaladoc/tasty/BasicSupport.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package tasty

import scala.jdk.CollectionConverters._
import dotty.tools.scaladoc._
import dotty.tools.scaladoc.cc.CaptureDefs
import scala.quoted._

import SymOps._
Expand Down Expand Up @@ -42,7 +43,7 @@ trait BasicSupport:
def getAnnotations(): List[Annotation] =
// Custom annotations should be documented only if annotated by @java.lang.annotation.Documented
// We allow also some special cases
val fqNameAllowlist = Set(
val fqNameAllowlist0 = Set(
"scala.specialized",
"scala.throws",
"scala.transient",
Expand All @@ -52,8 +53,12 @@ trait BasicSupport:
"scala.annotation.static",
"scala.annotation.targetName",
"scala.annotation.threadUnsafe",
"scala.annotation.varargs"
"scala.annotation.varargs",
)
val fqNameAllowlist =
if ccEnabled then
fqNameAllowlist0 + CaptureDefs.useAnnotFullName + CaptureDefs.consumeAnnotFullName
else fqNameAllowlist0
val documentedSymbol = summon[Quotes].reflect.Symbol.requiredClass("java.lang.annotation.Documented")
val annotations = sym.annotations.filter { a =>
a.tpe.typeSymbol.hasAnnotation(documentedSymbol) || fqNameAllowlist.contains(a.symbol.fullName)
Expand Down
Loading
Loading