Skip to content

Commit b92938f

Browse files
committed
[intellij-bazel][testing-support] specs2 --test_filter for single tests and scopes #SCL-25391
Following SCL-24481 (PR #709) which added ScalaTest+ZIO single-test support to BazelScalaTestRunLineMarkerLogic, this restores specs2 parity. Without it, clicking the gutter on a specs2 spec produces --test_filter=<classFQN>, which fails to match specs2-junit's scope-prefixed Description display names; bazel's JUnit4Runner reports "No tests found matching RegEx". Specs2BazelTestFilter is a Scala 3 port of the deleted Google IJwB plugin (bazelbuild/intellij commit f62b2670648d, file scala/src/com/google/idea/blaze/scala/run/Specs2Utils.java). Filter format identical to Google's: - test ref: <classFQN>#\Q<scope> should::<test>\E$ - scope ref: <classFQN>#\Q<scope> should\E:: A specs2 class identifier itself (no enclosing test/scope expression) falls back to <classFQN>.* which empirically works for scala_specs2_junit_test targets. The helper lives in testing-support (Scala 2.13) rather than intellij-bazel (Scala 3) because it is a pure PSI-to-string utility with no bazel-runtime dependency, and testing-support already has the ScalaFixtureTestCase infrastructure used to unit-test it (Specs2BazelTestFilterTest).
1 parent 1fa7396 commit b92938f

4 files changed

Lines changed: 239 additions & 1 deletion

File tree

scala/integration/intellij-bazel/src/org/jetbrains/plugins/scala/bazel/BazelScalaTestRunLineMarkerLogic.scala

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import org.jetbrains.plugins.scala.lang.psi.api.expr.{ScInfixExpr, ScMethodCall,
1111
import org.jetbrains.plugins.scala.lang.psi.api.statements.ScFunctionDefinition
1212
import org.jetbrains.plugins.scala.lang.psi.api.toplevel.typedef.{ScClass, ScDerivesClauseOwner, ScObject, ScTypeDefinition}
1313
import org.jetbrains.plugins.scala.testingSupport.test.scalatest.ScalaTestConfigurationProducer
14+
import org.jetbrains.plugins.scala.testingSupport.test.specs2.{Specs2BazelTestFilter, Specs2TestFramework}
1415

1516
/**
1617
* Utilities inside contain logic for running entire test classes and individual tests from ScalaTest and ZIO-test via Bazel.
@@ -39,7 +40,20 @@ private object BazelScalaTestRunLineMarkerLogic {
3940
}
4041

4142
def getSingleTestFilter(psiElement: PsiElement): String =
42-
getTestClass(psiElement).map(_.qualifiedName).orNull
43+
getTestClass(psiElement) match {
44+
case Some(clazz: ScTypeDefinition) if isSpecs2TestClass(clazz) =>
45+
Specs2BazelTestFilter
46+
.getContainingTestExprOrScope(psiElement)
47+
.flatMap(infix => Specs2BazelTestFilter.getTestFilter(clazz, infix))
48+
.getOrElse(s"${clazz.qualifiedName}.*")
49+
case Some(clazz) =>
50+
clazz.qualifiedName
51+
case None =>
52+
null
53+
}
54+
55+
private def isSpecs2TestClass(clazz: ScTypeDefinition): Boolean =
56+
Specs2TestFramework().isTestClass(clazz, /*canBePotential*/ false)
4357

4458
private def getTestClass(psiElement: PsiElement): Option[ScDerivesClauseOwner] = {
4559
val parentClassOfObject = PsiTreeUtil.getParentOfType(psiElement, classOf[ScClass], classOf[ScObject])
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package org.jetbrains.plugins.scala.testingSupport.test.specs2
2+
3+
import com.intellij.psi.PsiElement
4+
import com.intellij.psi.util.PsiTreeUtil
5+
import org.jetbrains.plugins.scala.lang.psi.api.expr.ScInfixExpr
6+
import org.jetbrains.plugins.scala.lang.psi.api.toplevel.typedef.ScTypeDefinition
7+
import org.jetbrains.plugins.scala.testingSupport.test.TestConfigurationUtil
8+
import org.jetbrains.plugins.scala.testingSupport.test.structureView.TestNodeProvider
9+
10+
import java.util.regex.Pattern
11+
12+
/**
13+
* Build a Bazel `--test_filter` regex for clicks on specs2 tests and scopes.
14+
*
15+
* Ported from the deleted Google IJwB Scala plugin (commit `f62b2670648d` of
16+
* `bazelbuild/intellij`, file `scala/src/com/google/idea/blaze/scala/run/Specs2Utils.java`).
17+
*
18+
* Filter format produced:
19+
* - test ref (`"x" in {…}` / `>>` / `!`): `<classFQN>#\Q<scope> should::<test text>\E$`
20+
* - scope ref (`"x" should {…}` / `can`): `<classFQN>#\Q<scope> should\E::`
21+
*
22+
* The trailing `$` (end-of-string anchor) on a test filter narrows to that one
23+
* test; the trailing `::` on a scope filter selects every test under the scope
24+
* (because the splitter prefixes each child).
25+
*
26+
* Precision is bounded by what Bazel's `JUnit4Runner` filter does with the
27+
* regex — for some specs2-junit / rules_scala combinations a click on one test
28+
* may also run same-named siblings from a different scope. That's acceptable
29+
* and matches the Google-era behaviour.
30+
*
31+
* @note Lives in `testing-support` rather than `intellij-bazel` because it is a
32+
* pure PSI-to-string utility with no Bazel-runtime dependency, and
33+
* `testing-support` already has the `ScalaFixtureTestCase` infrastructure
34+
* used to unit-test it.
35+
*/
36+
object Specs2BazelTestFilter {
37+
38+
/** Splitter between scope path and leaf test text in specs2-junit descriptions. */
39+
private val TestNamePartsSplitter = "::"
40+
41+
def getContainingTestExprOrScope(element: PsiElement): Option[ScInfixExpr] =
42+
findContainingInfix(element, e => TestNodeProvider.isSpecs2TestExpr(e) || TestNodeProvider.isSpecs2ScopeExpr(e))
43+
44+
def getContainingTestExpr(element: PsiElement): Option[ScInfixExpr] =
45+
findContainingInfix(element, TestNodeProvider.isSpecs2TestExpr)
46+
47+
def getContainingTestScope(element: PsiElement): Option[ScInfixExpr] =
48+
findContainingInfix(element, TestNodeProvider.isSpecs2ScopeExpr)
49+
50+
private def findContainingInfix(element: PsiElement, predicate: PsiElement => Boolean): Option[ScInfixExpr] = {
51+
var current: PsiElement = element
52+
while (current != null && !predicate(current)) {
53+
current = PsiTreeUtil.getParentOfType(current, classOf[ScInfixExpr])
54+
}
55+
Option(current).collect { case infix: ScInfixExpr => infix }
56+
}
57+
58+
/** For a scope `"x" should { … }` returns `"x should"`; `None` if the left-hand side isn't a static name. */
59+
def getSpecs2ScopeName(testScope: ScInfixExpr): Option[String] =
60+
staticTestName(testScope).map(name => s"$name ${testScope.operation.refName}")
61+
62+
/** For a test `"x" in { … }` returns `"x"`, or `"<scope> should::x"` if it is inside a scope. */
63+
def getSpecs2ScopedTestName(testCase: ScInfixExpr): Option[String] =
64+
staticTestName(testCase).map { testName =>
65+
getContainingTestScope(testCase).flatMap(getSpecs2ScopeName) match {
66+
case Some(scopeName) => s"$scopeName$TestNamePartsSplitter$testName"
67+
case None => testName
68+
}
69+
}
70+
71+
/**
72+
* Build the Bazel `--test_filter` regex.
73+
*
74+
* @return `Some(regex)` if `element` is recognised as a specs2 test or scope
75+
* with a static name; `None` if no usable name (e.g. interpolated string).
76+
*/
77+
def getTestFilter(testClass: ScTypeDefinition, element: PsiElement): Option[String] = {
78+
val (rawName, suffix): (Option[String], String) =
79+
if (TestNodeProvider.isSpecs2TestExpr(element))
80+
(getSpecs2ScopedTestName(element.asInstanceOf[ScInfixExpr]), "$")
81+
else if (TestNodeProvider.isSpecs2ScopeExpr(element))
82+
(getSpecs2ScopeName(element.asInstanceOf[ScInfixExpr]), TestNamePartsSplitter)
83+
else
84+
(None, "")
85+
86+
rawName.map { name =>
87+
// bazelbuild/intellij#176: parens in test names break the Bazel JUnit4 regex parser.
88+
val sanitized = name.trim.replace('(', '[').replace(')', ']')
89+
// bazelbuild/intellij#169: regex-escape everything else so user names are literal.
90+
s"${testClass.qualifiedName}#${Pattern.quote(sanitized)}$suffix"
91+
}
92+
}
93+
94+
private def staticTestName(infix: ScInfixExpr): Option[String] = {
95+
val opt = TestConfigurationUtil.getStaticTestName(infix.getFirstChild, /*allowSymbolLiterals*/ false)
96+
if (opt.isEmpty) None else Some(opt.get)
97+
}
98+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package org.jetbrains.plugins.scala.testingSupport.specs2
2+
3+
import org.jetbrains.plugins.scala.DependencyManagerBase._
4+
import org.jetbrains.plugins.scala.base.ScalaSdkOwner
5+
import org.jetbrains.plugins.scala.base.libraryLoaders.{IvyManagedLoader, LibraryLoader}
6+
7+
/**
8+
* Mixin for `ScalaSdkOwner`-based test fixtures (e.g. `GutterMarkersTestBase`)
9+
* that need specs2 jars on the classpath. Contributes loaders only — no
10+
* other behaviour. Pinned to the same version as
11+
* [[org.jetbrains.plugins.scala.testingSupport.specs2.specs2_scala_2_13_specs_4.Specs2_Scala_2_13_Specs_4_Base]].
12+
*/
13+
trait WithSpecs2_4 extends ScalaSdkOwner {
14+
abstract override protected def librariesLoaders: Seq[LibraryLoader] =
15+
super.librariesLoaders ++ Seq(
16+
IvyManagedLoader(("org.specs2" %% "specs2-core" % "4.13.0").transitive())
17+
)
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package org.jetbrains.plugins.scala.testingSupport.test.specs2
2+
3+
import com.intellij.psi.util.PsiTreeUtil
4+
import org.jetbrains.plugins.scala.{LatestScalaVersions, ScalaVersion}
5+
import org.jetbrains.plugins.scala.base.ScalaFixtureTestCase
6+
import org.jetbrains.plugins.scala.lang.psi.api.expr.ScInfixExpr
7+
import org.jetbrains.plugins.scala.lang.psi.api.toplevel.typedef.ScClass
8+
import org.jetbrains.plugins.scala.testingSupport.specs2.WithSpecs2_4
9+
import org.jetbrains.plugins.scala.testingSupport.test.structureView.TestNodeProvider
10+
import org.junit.Assert.{assertEquals, assertTrue}
11+
12+
import scala.jdk.CollectionConverters._
13+
14+
/**
15+
* Unit tests for [[Specs2BazelTestFilter]] — the Bazel `--test_filter`
16+
* builder ported from Google's deleted IJwB Scala plugin.
17+
*
18+
* Each test configures a single specs2 source file, finds the `ScInfixExpr`
19+
* for a particular test or scope, and asserts the regex string the Bazel
20+
* gutter logic would emit when the user clicks that location.
21+
*/
22+
class Specs2BazelTestFilterTest extends ScalaFixtureTestCase with WithSpecs2_4 {
23+
24+
override protected def supportedIn(version: ScalaVersion): Boolean =
25+
version == LatestScalaVersions.Scala_2_13
26+
27+
private val sourceText: String =
28+
"""import org.specs2.mutable.Specification
29+
|
30+
|class ExampleSpec extends Specification {
31+
| "a top-level test" in { ok }
32+
|
33+
| "scope A" should {
34+
| "scoped test 1" in { ok }
35+
| "scoped test 2" >> { ok }
36+
| }
37+
|
38+
| "scope B" can {
39+
| "scoped test 3" in { ok }
40+
| }
41+
|}
42+
|""".stripMargin
43+
44+
private def configure(): ScClass = {
45+
myFixture.configureByText("ExampleSpec.scala", sourceText)
46+
PsiTreeUtil.findChildOfType(myFixture.getFile, classOf[ScClass])
47+
}
48+
49+
private def findInfix(predicate: ScInfixExpr => Boolean): ScInfixExpr = {
50+
val all = PsiTreeUtil.findChildrenOfType(myFixture.getFile, classOf[ScInfixExpr]).asScala
51+
all.find(predicate).getOrElse(throw new AssertionError("no matching ScInfixExpr in fixture"))
52+
}
53+
54+
// ----- test ref clicks (in / >>) ------------------------------------
55+
56+
def testTopLevelTest_inOperator(): Unit = {
57+
val clazz = configure()
58+
val infix = findInfix(e => TestNodeProvider.isSpecs2TestExpr(e) && e.getText.startsWith("\"a top-level"))
59+
val filter = Specs2BazelTestFilter.getTestFilter(clazz, infix)
60+
assertEquals(Some("ExampleSpec#\\Qa top-level test\\E$"), filter)
61+
}
62+
63+
def testScopedTest_inOperator(): Unit = {
64+
val clazz = configure()
65+
val infix = findInfix(e => TestNodeProvider.isSpecs2TestExpr(e) && e.getText.startsWith("\"scoped test 1"))
66+
val filter = Specs2BazelTestFilter.getTestFilter(clazz, infix)
67+
assertEquals(Some("ExampleSpec#\\Qscope A should::scoped test 1\\E$"), filter)
68+
}
69+
70+
def testScopedTest_chevronOperator(): Unit = {
71+
val clazz = configure()
72+
val infix = findInfix(e => TestNodeProvider.isSpecs2TestExpr(e) && e.getText.startsWith("\"scoped test 2"))
73+
val filter = Specs2BazelTestFilter.getTestFilter(clazz, infix)
74+
assertEquals(Some("ExampleSpec#\\Qscope A should::scoped test 2\\E$"), filter)
75+
}
76+
77+
def testScopedTest_inCanScope(): Unit = {
78+
val clazz = configure()
79+
val infix = findInfix(e => TestNodeProvider.isSpecs2TestExpr(e) && e.getText.startsWith("\"scoped test 3"))
80+
val filter = Specs2BazelTestFilter.getTestFilter(clazz, infix)
81+
assertEquals(Some("ExampleSpec#\\Qscope B can::scoped test 3\\E$"), filter)
82+
}
83+
84+
// ----- scope ref clicks (should / can) ------------------------------
85+
86+
def testScope_should(): Unit = {
87+
val clazz = configure()
88+
val infix = findInfix(e => TestNodeProvider.isSpecs2ScopeExpr(e) && e.getText.startsWith("\"scope A\""))
89+
val filter = Specs2BazelTestFilter.getTestFilter(clazz, infix)
90+
assertEquals(Some("ExampleSpec#\\Qscope A should\\E::"), filter)
91+
}
92+
93+
def testScope_can(): Unit = {
94+
val clazz = configure()
95+
val infix = findInfix(e => TestNodeProvider.isSpecs2ScopeExpr(e) && e.getText.startsWith("\"scope B\""))
96+
val filter = Specs2BazelTestFilter.getTestFilter(clazz, infix)
97+
assertEquals(Some("ExampleSpec#\\Qscope B can\\E::"), filter)
98+
}
99+
100+
// ----- non-specs2 elements yield None -------------------------------
101+
102+
def testElementOutsideTestExpr_returnsNone(): Unit = {
103+
val clazz = configure()
104+
// The class declaration itself isn't a test/scope expr.
105+
val filter = Specs2BazelTestFilter.getTestFilter(clazz, clazz)
106+
assertTrue(filter.isEmpty)
107+
}
108+
}

0 commit comments

Comments
 (0)