From 6e29e592ce69bbe6615adb6d23ccf6fb041a2a33 Mon Sep 17 00:00:00 2001 From: Som Snytt Date: Tue, 23 Sep 2025 07:32:44 -0700 Subject: [PATCH 1/5] Always traverse Inlined.call in linter Previously it incorrectly only looked at call after typer, but it must also look at call after inlining, for intermediate expansions which are not directly written by user. [Cherry-picked 91646a89d22a96473d27f65af5397624e29aece0] --- .../tools/dotc/transform/CheckUnused.scala | 4 +- tests/warn/i15503a.scala | 12 +-- tests/warn/i24034/circe.scala | 97 +++++++++++++++++++ tests/warn/i24034/iron.scala | 30 ++++++ tests/warn/i24034/test.scala | 10 ++ 5 files changed, 143 insertions(+), 10 deletions(-) create mode 100644 tests/warn/i24034/circe.scala create mode 100644 tests/warn/i24034/iron.scala create mode 100644 tests/warn/i24034/test.scala diff --git a/compiler/src/dotty/tools/dotc/transform/CheckUnused.scala b/compiler/src/dotty/tools/dotc/transform/CheckUnused.scala index 61c683202955..579ae1e7bbf2 100644 --- a/compiler/src/dotty/tools/dotc/transform/CheckUnused.scala +++ b/compiler/src/dotty/tools/dotc/transform/CheckUnused.scala @@ -164,10 +164,8 @@ class CheckUnused private (phaseMode: PhaseMode, suffix: String) extends MiniPha refInfos.inlined.push(tree.call.srcPos) ctx override def transformInlined(tree: Inlined)(using Context): tree.type = - //transformAllDeep(tree.expansion) // traverse expansion with nonempty inlined stack to avoid registering defs + transformAllDeep(tree.call) val _ = refInfos.inlined.pop() - if !tree.call.isEmpty && phaseMode.eq(PhaseMode.Aggregate) then - transformAllDeep(tree.call) tree override def prepareForBind(tree: Bind)(using Context): Context = diff --git a/tests/warn/i15503a.scala b/tests/warn/i15503a.scala index 439799ee8e3d..795873b53e73 100644 --- a/tests/warn/i15503a.scala +++ b/tests/warn/i15503a.scala @@ -39,6 +39,10 @@ object FooGiven: val foo = summon[Int] +object SomeGivenImports: + given Int = 0 + given String = "foo" + /** * Import used as type name are considered * as used. @@ -69,7 +73,7 @@ object InlineChecks: object InlinedBar: import collection.mutable.Set // warn (don't be fooled by inline expansion) import collection.mutable.Map // warn - val a = InlineFoo.getSet + val a = InlineFoo.getSet // expansion is attributed mutable.Set.apply(1) object MacroChecks: object StringInterpol: @@ -91,12 +95,6 @@ object IgnoreExclusion: val a = Set(1) val b = Map(1 -> 2) def c = Seq(42) -/** - * Some given values for the test - */ -object SomeGivenImports: - given Int = 0 - given String = "foo" /* BEGIN : Check on packages*/ package nestedpackageimport: diff --git a/tests/warn/i24034/circe.scala b/tests/warn/i24034/circe.scala new file mode 100644 index 000000000000..5d2e646f67b2 --- /dev/null +++ b/tests/warn/i24034/circe.scala @@ -0,0 +1,97 @@ + +// circe.scala + +package io.circe + +import scala.compiletime.* +import scala.deriving.Mirror +import scala.quoted.* + +trait Encoder[A]: + def encode(value: A): String + +object Encoder: + trait AsObject[A] extends Encoder[A] + given Encoder[String] = ??? + +trait Configuration +object Configuration: + val default: Configuration = ??? + +object Default: + given [A]: Default[A] = ??? +trait Default[T] + +trait Codec[A] extends Encoder[A] +object Codec: + trait AsObject[A] extends Encoder.AsObject[A] + object AsObject: + inline final def derived[A](using inline A: Mirror.Of[A]): Codec.AsObject[A] = + ConfiguredCodec.derived[A](using Configuration.default) + inline final def derivedConfigured[A](using + inline A: Mirror.Of[A], + inline conf: Configuration + ): Codec.AsObject[A] = ConfiguredCodec.derived[A] + +trait ConfiguredEncoder[A](using conf: Configuration) extends Encoder.AsObject[A] +trait ConfiguredCodec[A] extends Codec.AsObject[A], ConfiguredEncoder[A] +object ConfiguredCodec: + inline final def derive[A: Mirror.Of](): ConfiguredCodec[A] = + derived[A](using Configuration.default) + inline final def derived[A](using + conf: Configuration, + inline mirror: Mirror.Of[A] + ): ConfiguredCodec[A] = ${ derivedImpl[A]('conf, 'mirror) } + def ofProduct[A]( + encoders: => List[Encoder[?]] + )(using Configuration, Default[A]): ConfiguredCodec[A] = ??? + def derivedImpl[A: Type](conf: Expr[Configuration], mirror: Expr[Mirror.Of[A]])(using + q: Quotes + ): Expr[ConfiguredCodec[A]] = { + mirror match { + case '{ + ${ _ }: Mirror.ProductOf[A] { + type MirroredLabel = l + type MirroredElemLabels = el + type MirroredElemTypes = et + } + } => + '{ + ConfiguredCodec.ofProduct[A]( + derivation.summonEncoders[et & Tuple](false)(using $conf) + )(using $conf) + } + } + } + +object derivation: + sealed trait Inliner[A, Arg]: + inline def apply[T](inline arg: Arg): A + + class EncoderNotDeriveSum(using config: Configuration) extends Inliner[Encoder[?], Unit]: + inline def apply[T](inline arg: Unit): Encoder[?] = summonEncoder[T](false) + + inline final def loopUnrolled[A, Arg, T <: Tuple](f: Inliner[A, Arg], inline arg: Arg): List[A] = + inline erasedValue[T] match + case _: EmptyTuple => Nil + case _: (h *: ts) => f[h](arg) :: loopUnrolled[A, Arg, ts](f, arg) + + inline def loopUnrolledNoArg[A, T <: Tuple](f: Inliner[A, Unit]): List[A] = + loopUnrolled[A, Unit, T](f, ()) + + inline final def summonEncoders[T <: Tuple](inline derivingForSum: Boolean)(using + Configuration + ): List[Encoder[?]] = + loopUnrolledNoArg[Encoder[?], T]( + inline if (derivingForSum) compiletime.error("unreachable") + else new EncoderNotDeriveSum + ) + + private[circe] inline final def summonEncoder[A]( + inline derivingForSum: Boolean + )(using Configuration): Encoder[A] = summonFrom { + case encodeA: Encoder[A] => encodeA + case _: Mirror.Of[A] => + inline if (derivingForSum) compiletime.error("unreachable") + else error("Failed to find an instance of Encoder[]") + } diff --git a/tests/warn/i24034/iron.scala b/tests/warn/i24034/iron.scala new file mode 100644 index 000000000000..17cb26bf6408 --- /dev/null +++ b/tests/warn/i24034/iron.scala @@ -0,0 +1,30 @@ + +// iron.scala + +package iron + +import io.circe.* + +opaque type IronType[A, C] <: A = A +type :|[A, C] = IronType[A, C] +trait Constraint[A, C] + +package constraint: + object string: + final class StartWith[V <: String] + object StartWith: + inline given [V <: String]: Constraint[String, StartWith[V]] = ??? + +object circe: + inline given XXX[A, B](using inline encoder: Encoder[A]): Encoder[A :| B] = ??? + inline given YYY[A, B](using inline encoder: Encoder[A], dummy: scala.util.NotGiven[DummyImplicit]): Encoder[A :| B] = ??? + // inline given [T](using mirror: RefinedTypeOps.Mirror[T], ev: Encoder[mirror.IronType]): Encoder[T] = ??? + +// trait RefinedTypeOps[A, C, T]: +// inline given RefinedTypeOps.Mirror[T] = ??? +// object RefinedTypeOps: +// trait Mirror[T]: +// type BaseType +// type ConstraintType +// type IronType = BaseType :| ConstraintType + diff --git a/tests/warn/i24034/test.scala b/tests/warn/i24034/test.scala new file mode 100644 index 000000000000..f87be8da2c36 --- /dev/null +++ b/tests/warn/i24034/test.scala @@ -0,0 +1,10 @@ +//> using options -Wunused:all + +import io.circe.Codec + +import iron.:| +import iron.circe.given +import iron.constraint.string.StartWith + +case class Alien(name: String :| StartWith["alen"]) derives Codec.AsObject + From 176a4f0add69d34746160b140f11c6fcf81d988c Mon Sep 17 00:00:00 2001 From: Som Snytt Date: Thu, 2 Oct 2025 17:11:05 -0700 Subject: [PATCH 2/5] Use enclosingInlineds [Cherry-picked 912e89ba70a5a0c694a8dd40d69b86e9ceb6966d] --- .../dotty/tools/dotc/transform/CheckUnused.scala | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/transform/CheckUnused.scala b/compiler/src/dotty/tools/dotc/transform/CheckUnused.scala index 579ae1e7bbf2..d247a9e20076 100644 --- a/compiler/src/dotty/tools/dotc/transform/CheckUnused.scala +++ b/compiler/src/dotty/tools/dotc/transform/CheckUnused.scala @@ -58,9 +58,10 @@ class CheckUnused private (phaseMode: PhaseMode, suffix: String) extends MiniPha if tree.symbol.exists then // if in an inline expansion, resolve at summonInline (synthetic pos) or in an enclosing call site val resolving = - refInfos.inlined.isEmpty - || tree.srcPos.isZeroExtentSynthetic - || refInfos.inlined.exists(_.sourcePos.contains(tree.srcPos.sourcePos)) + val inlineds = enclosingInlineds // per current context + inlineds.isEmpty + || tree.srcPos.isZeroExtentSynthetic // take as summonInline + || inlineds.last.srcPos.sourcePos.contains(tree.srcPos.sourcePos) if resolving && !ignoreTree(tree) then def loopOverPrefixes(prefix: Type, depth: Int): Unit = if depth < 10 && prefix.exists && !prefix.classSymbol.isEffectiveRoot then @@ -160,12 +161,8 @@ class CheckUnused private (phaseMode: PhaseMode, suffix: String) extends MiniPha case _ => tree - override def prepareForInlined(tree: Inlined)(using Context): Context = - refInfos.inlined.push(tree.call.srcPos) - ctx override def transformInlined(tree: Inlined)(using Context): tree.type = transformAllDeep(tree.call) - val _ = refInfos.inlined.pop() tree override def prepareForBind(tree: Bind)(using Context): Context = @@ -468,7 +465,7 @@ object CheckUnused: val nowarn = mutable.Set.empty[Symbol] // marked @nowarn val imps = new IdentityHashMap[Import, Unit] // imports val sels = new IdentityHashMap[ImportSelector, Unit] // matched selectors - def register(tree: Tree)(using Context): Unit = if inlined.isEmpty then + def register(tree: Tree)(using Context): Unit = if enclosingInlineds.isEmpty then tree match case imp: Import => if inliners == 0 @@ -497,7 +494,6 @@ object CheckUnused: if tree.symbol ne NoSymbol then defs.addOne((tree.symbol, tree.srcPos)) // TODO is this a code path - val inlined = Stack.empty[SrcPos] // enclosing call.srcPos of inlined code (expansions) var inliners = 0 // depth of inline def (not inlined yet) // instead of refs.addOne, use addRef to distinguish a read from a write to var From b47f0581e42deaec99b88b48024ab5d36134cf2a Mon Sep 17 00:00:00 2001 From: Som Snytt Date: Thu, 2 Oct 2025 17:24:41 -0700 Subject: [PATCH 3/5] Register expansion def for lint if in user code [Cherry-picked 1eb4e79aa7e6b92138ab4fd1c39b56d189b4c4b1] --- .../src/dotty/tools/dotc/transform/CheckUnused.scala | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/transform/CheckUnused.scala b/compiler/src/dotty/tools/dotc/transform/CheckUnused.scala index d247a9e20076..a1f88b51ae44 100644 --- a/compiler/src/dotty/tools/dotc/transform/CheckUnused.scala +++ b/compiler/src/dotty/tools/dotc/transform/CheckUnused.scala @@ -58,10 +58,8 @@ class CheckUnused private (phaseMode: PhaseMode, suffix: String) extends MiniPha if tree.symbol.exists then // if in an inline expansion, resolve at summonInline (synthetic pos) or in an enclosing call site val resolving = - val inlineds = enclosingInlineds // per current context - inlineds.isEmpty + tree.srcPos.isUserCode || tree.srcPos.isZeroExtentSynthetic // take as summonInline - || inlineds.last.srcPos.sourcePos.contains(tree.srcPos.sourcePos) if resolving && !ignoreTree(tree) then def loopOverPrefixes(prefix: Type, depth: Int): Unit = if depth < 10 && prefix.exists && !prefix.classSymbol.isEffectiveRoot then @@ -465,7 +463,7 @@ object CheckUnused: val nowarn = mutable.Set.empty[Symbol] // marked @nowarn val imps = new IdentityHashMap[Import, Unit] // imports val sels = new IdentityHashMap[ImportSelector, Unit] // matched selectors - def register(tree: Tree)(using Context): Unit = if enclosingInlineds.isEmpty then + def register(tree: Tree)(using Context): Unit = if tree.srcPos.isUserCode then tree match case imp: Import => if inliners == 0 @@ -1004,6 +1002,10 @@ object CheckUnused: extension (pos: SrcPos) def isZeroExtentSynthetic: Boolean = pos.span.isSynthetic && pos.span.isZeroExtent def isSynthetic: Boolean = pos.span.isSynthetic && pos.span.exists + def isUserCode(using Context): Boolean = + val inlineds = enclosingInlineds // per current context + inlineds.isEmpty + || inlineds.last.srcPos.sourcePos.contains(pos.sourcePos) extension [A <: AnyRef](arr: Array[A]) // returns `until` if not satisfied From b841becdc46b2acf58fe35b17a52c6ed15e3451b Mon Sep 17 00:00:00 2001 From: Som Snytt Date: Fri, 3 Oct 2025 15:27:15 -0700 Subject: [PATCH 4/5] Always resolve but restrict imports --- .../dotty/tools/dotc/transform/CheckUnused.scala | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/transform/CheckUnused.scala b/compiler/src/dotty/tools/dotc/transform/CheckUnused.scala index a1f88b51ae44..c5fcfadba2c2 100644 --- a/compiler/src/dotty/tools/dotc/transform/CheckUnused.scala +++ b/compiler/src/dotty/tools/dotc/transform/CheckUnused.scala @@ -1,4 +1,5 @@ -package dotty.tools.dotc.transform +package dotty.tools.dotc +package transform import dotty.tools.dotc.ast.desugar.{ForArtifact, PatternVar} import dotty.tools.dotc.ast.tpd.* @@ -60,14 +61,14 @@ class CheckUnused private (phaseMode: PhaseMode, suffix: String) extends MiniPha val resolving = tree.srcPos.isUserCode || tree.srcPos.isZeroExtentSynthetic // take as summonInline - if resolving && !ignoreTree(tree) then + if !ignoreTree(tree) then def loopOverPrefixes(prefix: Type, depth: Int): Unit = if depth < 10 && prefix.exists && !prefix.classSymbol.isEffectiveRoot then - resolveUsage(prefix.classSymbol, nme.NO_NAME, NoPrefix) + resolveUsage(prefix.classSymbol, nme.NO_NAME, NoPrefix, imports = resolving) loopOverPrefixes(prefix.normalizedPrefix, depth + 1) if tree.srcPos.isZeroExtentSynthetic then loopOverPrefixes(tree.typeOpt.normalizedPrefix, depth = 0) - resolveUsage(tree.symbol, tree.name, tree.typeOpt.importPrefix.skipPackageObject) + resolveUsage(tree.symbol, tree.name, tree.typeOpt.importPrefix.skipPackageObject, imports = resolving) else if tree.hasType then resolveUsage(tree.tpe.classSymbol, tree.name, tree.tpe.importPrefix.skipPackageObject) refInfos.isAssignment = false @@ -286,8 +287,11 @@ class CheckUnused private (phaseMode: PhaseMode, suffix: String) extends MiniPha * e.g., in `scala.Int`, `scala` is in scope for typer, but here we reverse-engineer the attribution. * For Select, lint does not look up `.scala` (so top-level syms look like magic) but records `scala.Int`. * For Ident, look-up finds the root import as usual. A competing import is OK because higher precedence. + * + * The `imports` flag is whether an identifier can mark an import as used: the flag is false + * for inlined code, except for `summonInline` (and related constructs) which are resolved at inlining. */ - def resolveUsage(sym0: Symbol, name: Name, prefix: Type)(using Context): Unit = + def resolveUsage(sym0: Symbol, name: Name, prefix: Type, imports: Boolean = true)(using Context): Unit = import PrecedenceLevels.* val sym = sym0.userSymbol @@ -391,7 +395,7 @@ class CheckUnused private (phaseMode: PhaseMode, suffix: String) extends MiniPha // record usage and possibly an import if !enclosed then refInfos.addRef(sym) - if candidate != NoContext && candidate.isImportContext && importer != null then + if imports && candidate != NoContext && candidate.isImportContext && importer != null then refInfos.sels.put(importer, ()) end resolveUsage From b454c6bf9358d9aa856d681974f737b664e81e21 Mon Sep 17 00:00:00 2001 From: Tomasz Godzik Date: Wed, 5 Nov 2025 13:56:49 +0100 Subject: [PATCH 5/5] Always resolve but restrict imports [Cherry-picked eb67a9c7172e9f4fc366cf6e9c69216893f3c05f][modified]