Skip to content

Analyzer enhancements #696

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

Merged
merged 31 commits into from
Jun 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
22b6e0f
add ImplicitValueClasses rule to enforce extending AnyVal for implici…
halotukozak Apr 15, 2025
e3c37cc
add FinalValueClasses rule to enforce marking value classes as final
halotukozak Apr 15, 2025
b189364
add ImplicitParamDefaults rule to prevent default values for implicit…
halotukozak Apr 15, 2025
7edce9a
ImplicitValueClasses rule: divide test into several ones, add Univers…
halotukozak Apr 17, 2025
9964dd4
do not run same tests twice
halotukozak Apr 17, 2025
746d402
refactor ImplicitValueClasses rule: move message construction inside …
halotukozak Apr 17, 2025
1a234f9
add CatchThrowable rule to discourage catching Throwable directly and…
halotukozak Apr 17, 2025
fbafdfb
add ImplicitFunctionParams rule to prevent implicit parameters from b…
halotukozak Apr 17, 2025
0f19b67
remove support for SAM types in ImplicitFunctionParams rule and enhan…
halotukozak Apr 17, 2025
441f2f6
enhance CatchThrowable rule to allow catching NonFatal exceptions and…
halotukozak Apr 17, 2025
04ff725
support NonFatal alias also
halotukozak Apr 17, 2025
1856639
add FinalCaseClasses rule to enforce final modifier on case classes a…
halotukozak Apr 18, 2025
563f6b4
enhance ImplicitValueClasses rule to prevent implicit classes from be…
halotukozak Apr 18, 2025
f37a935
enhance FinalCaseClasses rule to handle case classes defined inside t…
halotukozak Apr 18, 2025
d861b8c
enhance CatchThrowable rule to reject catching Throwable with pattern…
halotukozak Apr 25, 2025
4975fda
enhance CatchThrowable rule to improve pattern matching checks and ad…
halotukozak Apr 25, 2025
b72090f
enhance CatchThrowable rule to allow catching Throwable with custom e…
halotukozak Apr 25, 2025
a429ef1
enhance FinalCaseClasses rule to allow sealed case classes and add co…
halotukozak Apr 25, 2025
0c118fc
enhance Analyzer.md
halotukozak May 6, 2025
7456851
enhance rules and tests for FinalCaseClasses, ImplicitValueClasses, a…
halotukozak Jun 25, 2025
a13a1dc
update Analyzer.md to refine rule description for ImplicitValueClasses
halotukozak Jun 25, 2025
29496c5
simplify world
halotukozak Jun 25, 2025
165cc9f
nits
halotukozak Jun 25, 2025
8e6e538
refactor tests to use shared SAM and dummy definitions
halotukozak Jun 25, 2025
4274bff
introduce ScalaInterpolator for better analyzer test writing performance
halotukozak Jun 25, 2025
12c106d
agghghghhgh
halotukozak Jun 25, 2025
650f108
Merge remote-tracking branch 'origin/scala-interpolator-for-test' int…
halotukozak Jun 25, 2025
f20eb09
refactor tests to use Scala string interpolator (`scala`) for improve…
halotukozak Jun 25, 2025
658b5e6
refactor tests to use Scala string interpolator (`scala`) for improve…
halotukozak Jun 25, 2025
07c54e2
Merge remote-tracking branch 'origin/analyzer-enhancements' into anal…
halotukozak Jun 25, 2025
b57644d
update Analyzer documentation formatting for consistency and availabl…
halotukozak Jun 26, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ final class AnalyzerPlugin(val global: Global) extends Plugin { plugin =>
new BadSingletonComponent(global),
new ConstantDeclarations(global),
new BasePackage(global),
new ImplicitValueClasses(global),
new FinalValueClasses(global),
new FinalCaseClasses(global),
new ImplicitParamDefaults(global),
new CatchThrowable(global),
new ImplicitFunctionParams(global),
)

private lazy val rulesByName = rules.map(r => (r.name, r)).toMap
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ abstract class AnalyzerRule(val global: Global, val name: String, defaultLevel:
pos: Position,
message: String,
category: WarningCategory = WarningCategory.Lint,
site: Symbol = NoSymbol
site: Symbol = NoSymbol,
level: Level = this.level
): Unit =
level match {
case Level.Off =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.avsystem.commons
package analyzer

import scala.tools.nsc.Global

class CatchThrowable(g: Global) extends AnalyzerRule(g, "catchThrowable", Level.Warn) {

import global.*

private lazy val throwableTpe = typeOf[Throwable]

private def isCustomExtractor(tree: Tree): Boolean = tree match {
case UnApply(Apply(Select(_, TermName("unapply")), _), _) => true
case _ => false
}

private def checkTree(pat: Tree): Unit = if (pat.tpe != null && pat.tpe =:= throwableTpe && !isCustomExtractor(pat)) {
report(pat.pos, "Catching Throwable is discouraged, catch specific exceptions instead")
}

def analyze(unit: CompilationUnit): Unit =
unit.body.foreach {
case t: Try =>
t.catches.foreach {
case CaseDef(Alternative(trees), _, _) => trees.foreach(checkTree)
case CaseDef(Bind(_, Alternative(trees)), _, _) => trees.foreach(checkTree)
case CaseDef(pat, _, _) => checkTree(pat)
}
case _ =>
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.avsystem.commons
package analyzer

import com.avsystem.commons.analyzer.Level.Info

import scala.tools.nsc.Global

class FinalCaseClasses(g: Global) extends AnalyzerRule(g, "finalCaseClasses", Level.Warn) {

import global.*

def analyze(unit: CompilationUnit): Unit = unit.body.foreach {
case cd: ClassDef if !cd.mods.hasFlag(Flag.FINAL | Flag.SEALED) && cd.mods.hasFlag(Flag.CASE) =>
// Skip case classes defined inside traits (SI-4440)
val isInner = cd.symbol.isStatic

if (isInner) {
report(cd.pos, "Case classes should be marked as final")
} else {
report(cd.pos, "Case classes should be marked as final. Due to the SI-4440 bug, it cannot be done here. Consider moving the case class to the companion object", level = Info)
}
case _ =>
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.avsystem.commons
package analyzer

import scala.tools.nsc.Global

class FinalValueClasses(g: Global) extends AnalyzerRule(g, "finalValueClasses", Level.Warn) {

import global.*

private lazy val anyValTpe = typeOf[AnyVal]

def analyze(unit: CompilationUnit): Unit = unit.body.foreach {
case cd: ClassDef if !cd.mods.hasFlag(Flag.FINAL) =>
val tpe = cd.symbol.typeSignature

if (tpe.baseClasses.contains(anyValTpe.typeSymbol) ) {
report(cd.pos, "Value classes should be marked as final")
}
case _ =>
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.avsystem.commons
package analyzer

import scala.tools.nsc.Global

class ImplicitFunctionParams(g: Global) extends AnalyzerRule(g, "implicitFunctionParams", Level.Warn) {

import global.*

def analyze(unit: CompilationUnit): Unit = unit.body.foreach {
case dd: DefDef =>
dd.vparamss.foreach { paramList =>
if (paramList.nonEmpty && paramList.head.mods.hasFlag(Flag.IMPLICIT)) {
paramList.foreach { param =>
val paramTpe = param.tpt.tpe
if (paramTpe != null && (definitions.isFunctionType(paramTpe) || definitions.isPartialFunctionType(paramTpe))) {
report(param.pos, "Implicit parameters should not have any function type")
}
}
}
}
case _ =>
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.avsystem.commons
package analyzer

import scala.tools.nsc.Global

class ImplicitParamDefaults(g: Global) extends AnalyzerRule(g, "implicitParamDefaults", Level.Warn) {

import global.*

def analyze(unit: CompilationUnit): Unit = unit.body.foreach {
case dd: DefDef =>
dd.vparamss.foreach { paramList =>
if (paramList.nonEmpty && paramList.head.mods.hasFlag(Flag.IMPLICIT)) {
paramList.foreach { param =>
if (param.rhs != EmptyTree) {
report(param.pos, "Implicit parameters should not have default values")
}
}
}
}
case _ =>
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.avsystem.commons
package analyzer

import scala.tools.nsc.Global

class ImplicitValueClasses(g: Global) extends AnalyzerRule(g, "implicitValueClasses", Level.Warn) {

import global.*
import definitions.*

private lazy val defaultClasses = Set[Symbol](AnyClass, AnyValClass, ObjectClass)

private lazy val reportOnNestedClasses = argument match {
case "all" => true
case "top-level-only" | null => false
case _ => throw new IllegalArgumentException(s"Unknown ImplicitValueClasses option: $argument")
}

def analyze(unit: CompilationUnit): Unit = unit.body.foreach {
case cd: ClassDef if cd.mods.hasFlag(Flag.IMPLICIT) =>
val tpe = cd.symbol.typeSignature
val primaryCtor = tpe.member(termNames.CONSTRUCTOR).alternatives.find(_.asMethod.isPrimaryConstructor)
val paramLists = primaryCtor.map(_.asMethod.paramLists)
val inheritsAnyVal = tpe.baseClasses contains AnyValClass

def inheritsOtherClass = tpe.baseClasses.exists { cls =>
def isDefault = defaultClasses contains cls

def isUniversalTrait = cls.isTrait && cls.superClass == AnyClass

cls != cd.symbol && !isDefault && !isUniversalTrait
}

def hasExactlyOneParam = paramLists.exists(lists => lists.size == 1 && lists.head.size == 1)

def paramIsValueClass = paramLists.exists { lists =>
/* lists.nonEmpty && lists.head.nonEmpty && */
lists.head.head.typeSignature.typeSymbol.isDerivedValueClass
}

if (!inheritsAnyVal && !inheritsOtherClass && hasExactlyOneParam && !paramIsValueClass) {
val isNestedClass =
//implicit classes are always nested classes, so we want to check if the outer class's an object
/*cd.symbol.isNestedClass &&*/ !cd.symbol.isStatic

val message = "Implicit classes should always extend AnyVal to become value classes" +
(if (isNestedClass) ". Nested classes should be extracted to top-level objects" else "")

if (reportOnNestedClasses || !isNestedClass)
report(cd.pos, message)
else
report(cd.pos, message, level = Level.Info)
}
case _ =>
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.avsystem.commons
package analyzer

import com.avsystem.commons.analyzer.AnalyzerTest.ScalaInterpolator
import org.scalactic.source.Position
import org.scalatest.Assertions

Expand All @@ -25,11 +26,6 @@ trait AnalyzerTest { this: Assertions =>
run.compileSources(List(new BatchSourceFile("test.scala", source)))
}

def assertErrors(source: String)(implicit pos: Position): Unit = {
compile(source)
assert(compiler.reporter.hasErrors)
}

def assertErrors(errors: Int, source: String)(implicit pos: Position): Unit = {
compile(source)
assert(compiler.reporter.errorCount == errors)
Expand All @@ -39,4 +35,12 @@ trait AnalyzerTest { this: Assertions =>
compile(source)
assert(!compiler.reporter.hasErrors)
}

implicit final def stringContextToScalaInterpolator(sc: StringContext): ScalaInterpolator = new ScalaInterpolator(sc)
}

object AnalyzerTest {
final class ScalaInterpolator(private val sc: StringContext) extends AnyVal {
def scala(args: Any*): String = s"object TopLevel {${sc.s(args *)}}"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,29 @@ package analyzer

import org.scalatest.funsuite.AnyFunSuite

class Any2StringAddTest extends AnyFunSuite with AnalyzerTest {
final class Any2StringAddTest extends AnyFunSuite with AnalyzerTest {
test("any2stringadd should be rejected") {
assertErrors(
"""
|object whatever {
| whatever + "fag"
|}
""".stripMargin
)
assertErrors(1,
scala"""
|val any: Any = ???
|any + "fag"
|""".stripMargin)
}

test("toString should not be rejected") {
assertNoErrors(
"""
|object whatever {
| whatever.toString + "fag"
|}
""".stripMargin
scala"""
|val any: Any = ???
|any.toString + "fag"
|""".stripMargin
)
}

test("string interpolation should not be rejected") {
assertNoErrors(
"""
|object whatever {
| s"${whatever}fag"
|}
""".stripMargin
)
scala"""
|val any: Any = ???
|s"$${any}fag"
|""".stripMargin)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,27 @@ package analyzer

import org.scalatest.funsuite.AnyFunSuite

class BadSingletonComponentTest extends AnyFunSuite with AnalyzerTest {
final class BadSingletonComponentTest extends AnyFunSuite with AnalyzerTest {
test("general") {
assertErrors(5,
"""
|import com.avsystem.commons.di._
|
|object test extends Components {
| singleton(123)
| val notDef = singleton(123)
| def hasParams(param: Int) = singleton(param)
| def hasTypeParams[T]: Component[T] = singleton(???)
| def outerMethod: Component[Int] = {
| def innerMethod = singleton(123)
| innerMethod
| }
|
| def good: Component[Int] = singleton(123)
| def alsoGood: Component[Int] = { singleton(123) }
| def goodAsWell: Component[Int] = singleton(123).dependsOn(good)
|}
""".stripMargin
scala"""
|import com.avsystem.commons.di._
|
|object test extends Components {
| singleton(123)
| val notDef = singleton(123)
| def hasParams(param: Int) = singleton(param)
| def hasTypeParams[T]: Component[T] = singleton(???)
| def outerMethod: Component[Int] = {
| def innerMethod = singleton(123)
| innerMethod
| }
|
| def good: Component[Int] = singleton(123)
| def alsoGood: Component[Int] = { singleton(123) }
| def goodAsWell: Component[Int] = singleton(123).dependsOn(good)
|}
|""".stripMargin
)
}
}
Loading