From 4ae3305bad7a152f4932d4ea343e4600eace9acb Mon Sep 17 00:00:00 2001 From: akefirad Date: Sat, 25 Jan 2025 12:41:52 +0100 Subject: [PATCH] GH-55 add assert hint for spock mocks --- .github/workflows/build.yml | 30 +++++--- settings.gradle.kts | 9 +++ .../groom/spock/AssertInlayHintsProvider.kt | 57 ++++++++++---- .../akefirad/groom/spock/SpecLabelElement.kt | 1 - .../akefirad/groom/spock/SpockSpecUtils.kt | 8 +- .../spock/AssertInlayHintsProviderTest.groovy | 74 ++++++++++++++++++- 6 files changed, 144 insertions(+), 35 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ae6993c..65a9368 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,25 +7,29 @@ # - Create a draft release. name: Build + on: push: - branches: [ main ] + branches: [main] pull_request: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read # This is required for actions/checkout + pull-requests: write + jobs: build: - name: Build + name: Build Code runs-on: ubuntu-latest outputs: version: ${{ steps.properties.outputs.version }} changelog: ${{ steps.properties.outputs.changelog }} pluginVerifierHomeDir: ${{ steps.properties.outputs.pluginVerifierHomeDir }} steps: - - name: Checkout repository uses: actions/checkout@v4 @@ -40,6 +44,8 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 + with: + add-job-summary-as-pr-comment: on-failure - name: Export properties id: properties @@ -77,10 +83,9 @@ jobs: test-code: name: Test Code - needs: [ build ] + needs: [build] runs-on: ubuntu-latest steps: - - name: Checkout repository uses: actions/checkout@v4 @@ -92,6 +97,8 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 + with: + add-job-summary-as-pr-comment: on-failure - name: Run tests run: ./gradlew check @@ -111,14 +118,13 @@ jobs: inspect-code: name: Inspect Code - needs: [ build ] + needs: [build] runs-on: ubuntu-latest permissions: contents: write checks: write pull-requests: write steps: - - name: Maximize build space uses: jlumbroso/free-disk-space@main with: @@ -141,16 +147,15 @@ jobs: uses: JetBrains/qodana-action@v2024.3 env: QODANA_TOKEN: ${{ secrets.QODANA_TOKEN_205267377 }} - QODANA_ENDPOINT: 'https://qodana.cloud' + QODANA_ENDPOINT: "https://qodana.cloud" with: cache-default-branch-only: true verify-plugin: name: Verify Plugin - needs: [ build ] + needs: [build] runs-on: ubuntu-latest steps: - - name: Maximize build space uses: jlumbroso/free-disk-space@main with: @@ -168,6 +173,8 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 + with: + add-job-summary-as-pr-comment: on-failure - name: Setup plugin verifier IDEs cache uses: actions/cache@v4 @@ -188,12 +195,11 @@ jobs: release-draft: name: Release Draft if: github.event_name != 'pull_request' # TODO: run only on main? - needs: [ build, test-code, inspect-code, verify-plugin ] + needs: [build, test-code, inspect-code, verify-plugin] runs-on: ubuntu-latest permissions: contents: write steps: - - name: Checkout repository uses: actions/checkout@v4 diff --git a/settings.gradle.kts b/settings.gradle.kts index 12c127e..7c5a9c3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,14 @@ plugins { + id("com.gradle.develocity") version "3.18.1" id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0" } rootProject.name = "groom" + +develocity { + buildScan { + termsOfUseUrl = "https://gradle.com/terms-of-service" + termsOfUseAgree = "yes" + publishing.onlyIf { it.buildResult.failures.isNotEmpty() } + } +} diff --git a/src/main/kotlin/com/akefirad/groom/spock/AssertInlayHintsProvider.kt b/src/main/kotlin/com/akefirad/groom/spock/AssertInlayHintsProvider.kt index 329d9a4..2054ccb 100644 --- a/src/main/kotlin/com/akefirad/groom/spock/AssertInlayHintsProvider.kt +++ b/src/main/kotlin/com/akefirad/groom/spock/AssertInlayHintsProvider.kt @@ -1,6 +1,8 @@ package com.akefirad.groom.spock import com.akefirad.groom.intellij.PsiElementExtensions.startOffset +import com.akefirad.groom.spock.SpockSpecUtils.hasAnySpecification +import com.akefirad.groom.spock.SpockSpecUtils.isSpecification import com.akefirad.groom.spock.SpockSpecUtils.isSpeckLabel import com.intellij.codeInsight.hints.declarative.InlayHintsProvider import com.intellij.codeInsight.hints.declarative.InlayTreeSink @@ -11,27 +13,29 @@ import com.intellij.openapi.editor.Editor import com.intellij.openapi.project.PossiblyDumbAware import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile -import org.jetbrains.plugins.groovy.lang.psi.GroovyFile import org.jetbrains.plugins.groovy.lang.psi.api.statements.blocks.GrClosableBlock import org.jetbrains.plugins.groovy.lang.psi.api.statements.blocks.GrOpenBlock import org.jetbrains.plugins.groovy.lang.psi.api.statements.expressions.GrAssignmentExpression import org.jetbrains.plugins.groovy.lang.psi.api.statements.expressions.GrExpression import org.jetbrains.plugins.groovy.lang.psi.api.statements.expressions.path.GrMethodCallExpression import org.jetbrains.plugins.groovy.lang.psi.api.statements.typedef.members.GrMethod +import org.jetbrains.plugins.groovy.lang.psi.impl.statements.expressions.arithmetic.GrMultiplicativeExpressionImpl import org.jetbrains.plugins.groovy.lang.psi.util.isWhiteSpaceOrNewLine class AssertInlayHintsProvider : InlayHintsProvider, PossiblyDumbAware { override fun createCollector(file: PsiFile, editor: Editor): AssertInlayHintsCollector? { // TODO: check if it's an actual test file! - return if (file is GroovyFile) AssertInlayHintsCollector() else null + return if (file.hasAnySpecification()) AssertInlayHintsCollector() else null } } class AssertInlayHintsCollector : SharedBypassCollector { override fun collectFromElement(element: PsiElement, sink: InlayTreeSink) { - if (element is GrMethod) // TODO: check if it's a test method! + // TODO: also check if it's a test method! + if (element is GrMethod && element.isSpecification()) { collectFromMethod(element, sink) + } } private fun collectFromMethod(m: GrMethod, sink: InlayTreeSink) { @@ -41,6 +45,8 @@ class AssertInlayHintsCollector : SharedBypassCollector { } private fun collectFromBlock(b: GrOpenBlock, sink: InlayTreeSink) { + fun SpecLabelElement?.isExpectation() = this?.name?.isExpectation == true + var label: SpecLabelElement? = null for (c in b.children) { if (c.isWhiteSpaceOrNewLine()) { @@ -49,11 +55,11 @@ class AssertInlayHintsCollector : SharedBypassCollector { if (c.isSpeckLabel()) { val current = SpecLabelElement.ofLabel(c) - if (current.isContinuation && label?.isExpectation == true) { + if (current.isContinuation && label.isExpectation()) { if (current.hasTitle == false) { collectFromExpectationBlockChildren(c.lastChild, sink) } - } else if (current.isExpectation) { + } else if (current.isExpectation()) { if (current.hasTitle == false) { collectFromExpectationBlockChildren(c.lastChild, sink) } @@ -61,7 +67,7 @@ class AssertInlayHintsCollector : SharedBypassCollector { } else { label = null } - } else if (label?.isExpectation == true) { + } else if (label.isExpectation()) { collectFromExpectationBlockChildren(c, sink) } @@ -69,27 +75,46 @@ class AssertInlayHintsCollector : SharedBypassCollector { } private fun collectFromExpectationBlockChildren(e: PsiElement, sink: InlayTreeSink) { - class AssertInlayHint : (PresentationTreeBuilder) -> Unit { - override fun invoke(builder: PresentationTreeBuilder) = builder.text("assert") - } - if (e !is GrExpression || e is GrAssignmentExpression) return if (e.isSpockInteractionExpression()) return - if (e.isVerifyAllExpression() || e.isWithExpression()) { - val closure = e.lastChild as? GrClosableBlock ?: return - closure.children.forEach { collectFromExpectationBlockChildren(it, sink) } + if (e.isSpockMockExpression()) { + e.addAssertInlayHint(sink) + e.lastChild?.let { collectFromClosableBlock(it.lastChild, sink) } + } else if (e.isVerifyAllExpression()) { + collectFromClosableBlock(e.lastChild, sink) + } else if (e.isWithExpression()) { + collectFromClosableBlock(e.lastChild, sink) } else { - val position = InlineInlayPosition(e.startOffset, relatedToPrevious = false) - sink.addPresentation(position, hasBackground = true, builder = AssertInlayHint()) + e.addAssertInlayHint(sink) } } - private fun GrExpression.isSpockInteractionExpression() = isMethodCallExpression("interaction") + private fun collectFromClosableBlock(e: PsiElement?, sink: InlayTreeSink) { + val closure = e as? GrClosableBlock ?: return + closure.children.forEach { collectFromExpectationBlockChildren(it, sink) } + } + private fun GrExpression.isVerifyAllExpression() = isMethodCallExpression("verifyAll") private fun GrExpression.isWithExpression() = isMethodCallExpression("with") + private fun GrExpression.isSpockInteractionExpression() = isMethodCallExpression("interaction") + private fun GrExpression.isSpockMockExpression(): Boolean { + return this is GrMultiplicativeExpressionImpl // TODO: use GrBinaryExpression and check the operator! + && rightOperand is GrMethodCallExpression // TODO: anything else to check? + && leftOperand.text.let { it == "_" || it.matches("\\d+".toRegex()) } // FIXME: do it properly! + } + // TODO: this is too naive, we need to handle all edge cases! private fun GrExpression.isMethodCallExpression(method: String) = this is GrMethodCallExpression && callReference?.methodName == method + + private fun GrExpression.addAssertInlayHint(sink: InlayTreeSink) { + class AssertInlayHint : (PresentationTreeBuilder) -> Unit { + override fun invoke(builder: PresentationTreeBuilder) = builder.text("assert") + } + + val position = InlineInlayPosition(startOffset, relatedToPrevious = false) + sink.addPresentation(position, hasBackground = true, builder = AssertInlayHint()) + } } diff --git a/src/main/kotlin/com/akefirad/groom/spock/SpecLabelElement.kt b/src/main/kotlin/com/akefirad/groom/spock/SpecLabelElement.kt index fb6e6aa..fdc7481 100644 --- a/src/main/kotlin/com/akefirad/groom/spock/SpecLabelElement.kt +++ b/src/main/kotlin/com/akefirad/groom/spock/SpecLabelElement.kt @@ -49,7 +49,6 @@ data class SpecLabelElement(val element: GrLabeledStatement) { val title = element.title val hasTitle = title != null val isContinuation = name.isContinuation - val isExpectation = name.isExpectation override fun toString() = "$name${if (hasTitle) ": '$title'" else ""}" diff --git a/src/main/kotlin/com/akefirad/groom/spock/SpockSpecUtils.kt b/src/main/kotlin/com/akefirad/groom/spock/SpockSpecUtils.kt index c5c15f6..eb46e2e 100644 --- a/src/main/kotlin/com/akefirad/groom/spock/SpockSpecUtils.kt +++ b/src/main/kotlin/com/akefirad/groom/spock/SpockSpecUtils.kt @@ -1,15 +1,18 @@ package com.akefirad.groom.spock +import com.intellij.psi.PsiClass import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.plugins.groovy.lang.psi.GroovyFile import org.jetbrains.plugins.groovy.lang.psi.api.statements.GrLabeledStatement import kotlin.contracts.contract object SpockSpecUtils { - /* private const val SPOCK_SPEC_CLASS: String = "spock.lang.Specification" @JvmStatic - fun PsiElement.hasAnySpecification(): Boolean { + fun PsiFile.hasAnySpecification(): Boolean { return this is GroovyFile && children .filterIsInstance() .any(SpockSpecUtils::hasSpecificationSuperClass) @@ -23,7 +26,6 @@ object SpockSpecUtils { private fun hasSpecificationSuperClass(clazz: PsiClass) = generateSequence(clazz) { it.superClass } .any { it.qualifiedName == SPOCK_SPEC_CLASS } - */ fun PsiElement.isSpeckLabel(): Boolean { contract { diff --git a/src/test/groovy/com/akefirad/groom/spock/AssertInlayHintsProviderTest.groovy b/src/test/groovy/com/akefirad/groom/spock/AssertInlayHintsProviderTest.groovy index 36f5856..b21c774 100644 --- a/src/test/groovy/com/akefirad/groom/spock/AssertInlayHintsProviderTest.groovy +++ b/src/test/groovy/com/akefirad/groom/spock/AssertInlayHintsProviderTest.groovy @@ -129,7 +129,7 @@ class AssertInlayHintsProviderTest extends LightPlatformCodeInsightFixture4TestC } @Test - void 'provider should provide implicit assert'() { + void 'provider should provide implicit assert for complex code'() { def code = """ $SpecificationClass @@ -296,6 +296,73 @@ class AssertInlayHintsProviderTest extends LightPlatformCodeInsightFixture4TestC testAnnotations(code) } + @Test + void 'provider should provide implicit assert for spock mocks'() { + def code = """ + $SpecificationClass + + class MySpec extends Specification { + def 'something'() { + given: 'a foo and bar' + def foo = 1 + def bar = mock(Object) + + when: 'foo + bar' + def baz = add(foo, bar) + + then: 'should be 3' + /*<# assert #>*/_ * bar.foo() + /*<# assert #>*/1 * bar.foo() + /*<# assert #>*/1 * bar.bar(foo) + /*<# assert #>*/1 * bar.foo() >> 1 + /*<# assert #>*/1 * bar.bar(foo) >> 2 + + and: 'nested assertions' + /*<# assert #>*/_ * bar.foo { + /*<# assert #>*/it.foo == 'foo' + } + /*<# assert #>*/1 * bar.foo { + /*<# assert #>*/it.foo == 'foo' + } + + and: 'nested verifyAll' + /*<# assert #>*/1 * bar.foo { + verifyAll(baz) { + /*<# assert #>*/it.baz == 3 + /*<# assert #>*/it.baz == add(foo, bar) + } + } + + and: 'nested with' + /*<# assert #>*/1 * bar.foo { + with(baz) { + /*<# assert #>*/it.baz == 3 + /*<# assert #>*/it.baz == add(foo, bar) + } + } + + and: 'non-mock' + /*<# assert #>*/1 + 2 + /*<# assert #>*/1 * 2 + /*<# assert #>*/1 + bar.foo() + /*<# assert #>*/1.2 + bar.foo() + /*<# assert #>*/foo.bar() + bar.foo() + /*<# assert #>*/1.2 * bar.foo() + /*<# assert #>*/1.2 * bar.foo { + it.baz == 3 + with(baz) { + it.baz == 3 + } + verifyAll(baz) { + it.baz == 3 + } + } + } + } + """.stripIndent() + testAnnotations(code) + } + @Test void 'provider should not provide implicit assert when assert is present'() { def code = """ @@ -336,9 +403,11 @@ class AssertInlayHintsProviderTest extends LightPlatformCodeInsightFixture4TestC } @Test - void 'provider should do nothing when file is not GroovyFile'() { + void 'provider should do nothing when file is not spock specification'() { given: def code = ''' + package foo.bar; + class Specification {} class SampleSpec extends Specification { void test_method() { given: "some given block" @@ -351,7 +420,6 @@ class AssertInlayHintsProviderTest extends LightPlatformCodeInsightFixture4TestC baz == 'foobar'; } } - '''.stripIndent() when: