Skip to content
Draft
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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# AGENTS.md

Project instructions are maintained in [CLAUDE.md](CLAUDE.md). Read and follow that file before making changes.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

## [Unreleased]

### Fixed

- List tests sharing a data provider in source order for a consistent default selection.
- Preserve stopped mutation reruns in run history after restarting the IDE, and protect active mutation archives
during cleanup.

## [2026.16.262] - 2026-10-01

### Fixed
Expand Down
174 changes: 116 additions & 58 deletions CLAUDE.md

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ kotlin {
jvmToolchain(21)
}

// PHP-specific implementations and registrations are compiled alongside the shared core.
sourceSets {
main {
kotlin.srcDir("src/phpstorm/kotlin")
resources.srcDir("src/phpstorm/resources")
}
test {
kotlin.srcDir("src/phpstormTest/kotlin")
}
}

// Configure project's dependencies
repositories {
mavenCentral()
Expand Down Expand Up @@ -129,6 +140,9 @@ tasks {
autoReload = false
}
test {
// VFS resolves macOS /var symlinks; fixtures and file lookups must use the same temporary path.
systemProperty("java.io.tmpdir", file(System.getProperty("java.io.tmpdir")).canonicalPath)

// The bundled Kotlin plugin's KotlinScriptDefinitionCodeVisionProvider cannot be instantiated in the 2026.2
// test fixture (its message bundle is missing the provider's name key), and the error is logged from a
// project startup activity — outside any window a test could guard — failing whichever test the project
Expand Down
15 changes: 15 additions & 0 deletions src/main/kotlin/com/github/xepozz/testo/TestoAttributes.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.github.xepozz.testo

import com.github.xepozz.testo.php.PhpAttributeView
import com.intellij.openapi.util.text.StringUtil

/**
* The group names of a `#[Group('db', 'slow')]` attribute, in source order. Read the way an indexer may read them
* (see [PhpAttributeView.argument]); arguments that are not string literals (constants, concatenations) cannot be
* resolved there and are skipped, and so are blank names.
*/
fun groupNamesOf(attribute: PhpAttributeView): List<String> = attribute.arguments
.filterNotNull()
.filter { it.isStringLiteral }
.map { StringUtil.unquoteString(it.text) }
.filter { it.isNotBlank() }
40 changes: 21 additions & 19 deletions src/main/kotlin/com/github/xepozz/testo/TestoContext.kt
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
package com.github.xepozz.testo

import com.github.xepozz.testo.php.PhpClassView
import com.github.xepozz.testo.php.TestoPhp
import com.intellij.codeInsight.template.TemplateActionContext
import com.intellij.codeInsight.template.TemplateContextType
import com.intellij.openapi.fileTypes.SyntaxHighlighter
import com.intellij.psi.PsiElement
import com.jetbrains.php.lang.lexer.PhpTokenTypes
import com.jetbrains.php.lang.psi.PhpPsiUtil
import com.jetbrains.php.lang.psi.elements.PhpClass
import com.jetbrains.php.liveTemplates.PhpTemplateContextType

class TestoContext : PhpTemplateContextType("Testo") {
override fun isInContext(element: PsiElement): Boolean {
val parent = element.parent
return when {
parent !is PhpClass -> false
!element.containingFile.isTestoFile() -> false
else -> {
val openBrace = PhpPsiUtil.getChildOfType(parent, PhpTokenTypes.chLBRACE)
when {
openBrace != null && openBrace.textOffset <= element.textOffset -> true
else -> false
}
}
}
/** Inside the body of a class in a Testo file. The base context (`baseContextId="PHP"`) is set in plugin.xml. */
class TestoContext : TemplateContextType("Testo") {
override fun isInContext(templateActionContext: TemplateActionContext): Boolean {
val element = templateActionContext.file.findElementAt(templateActionContext.startOffset) ?: return false
return isInContext(element)
}
}

// The template editor highlights a template the way its context does; PHP's context knows how.
override fun createHighlighter(): SyntaxHighlighter? = baseContextType?.createHighlighter()

fun isInContext(element: PsiElement): Boolean {
val parent = TestoPhp.getInstance().view(element.parent) as? PhpClassView ?: return false
if (!element.containingFile.isTestoFile()) return false
val bodyStart = parent.bodyStartOffset ?: return false
return bodyStart <= element.textOffset
}
}
16 changes: 3 additions & 13 deletions src/main/kotlin/com/github/xepozz/testo/TestoUtil.kt
Original file line number Diff line number Diff line change
@@ -1,18 +1,8 @@
package com.github.xepozz.testo

import com.github.xepozz.testo.tests.TestoFrameworkType
import com.github.xepozz.testo.php.TestoPhp
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.text.StringUtil
import com.jetbrains.php.testFramework.PhpTestFrameworkSettingsManager

object TestoUtil {
fun isEnabled(project: Project): Boolean =
PhpTestFrameworkSettingsManager
.getInstance(project)
.getConfigurations(TestoFrameworkType.INSTANCE)
.firstOrNull()
?.let { configurations ->
!configurations.isLocal || StringUtil.isNotEmpty(configurations.executablePath)
}
?: false
}
fun isEnabled(project: Project): Boolean = TestoPhp.getInstance().isTestoConfigured(project)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,29 @@ package com.github.xepozz.testo.coverage

import com.github.xepozz.testo.coverage.format.CoverageFormat
import com.github.xepozz.testo.coverage.format.LineTotals
import com.github.xepozz.testo.tests.run.TestoRunConfiguration
import com.github.xepozz.testo.launch.TestoConfiguration
import com.github.xepozz.testo.php.TestoPhp
import com.intellij.coverage.BaseCoverageSuite
import com.intellij.coverage.CoverageAnnotator
import com.intellij.coverage.CoverageEngine
import com.intellij.coverage.CoverageFileProvider
import com.intellij.coverage.CoverageRunner
import com.intellij.coverage.CoverageSuite
import com.intellij.coverage.CoverageSuitesBundle
import com.intellij.coverage.BaseCoverageSuite
import com.intellij.coverage.view.CoverageViewExtension
import com.intellij.execution.configurations.RunConfigurationBase
import com.intellij.execution.configurations.coverage.CoverageEnabledConfiguration
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiFile
import com.jetbrains.php.lang.psi.PhpFile
import java.nio.file.Path

/**
* Testo coverage on 100% public platform API — no `com.intellij.php.coverage.*` (internal, closed to third-party
* plugins) and no deprecated `com.jetbrains.php.phpunit.coverage.*`. The report path is IDE-managed, so we pass it to
* the CLI and read it back where the IDE expects it (see [TestoCoverageProgramRunner]).
* plugins) and none of the PHP plugin's deprecated PHPUnit coverage classes. The report path is IDE-managed, so we
* pass it to the CLI and read it back where the IDE expects it (see the PHP implementation's coverage runner).
*/
class TestoCoverageEnabledConfiguration(
configuration: TestoRunConfiguration,
configuration: RunConfigurationBase<*>,
) : CoverageEnabledConfiguration(configuration, CoverageRunner.getInstance(TestoCoverageRunner::class.java)) {
override fun coverageFileNameSeparator(): String = "@"
}
Expand Down Expand Up @@ -75,10 +75,10 @@ class TestoCoverageSuite : BaseCoverageSuite {
class TestoCoverageEngine : CoverageEngine() {
override fun getPresentableText(): String = "Testo"

override fun isApplicableTo(conf: RunConfigurationBase<*>): Boolean = conf is TestoRunConfiguration
override fun isApplicableTo(conf: RunConfigurationBase<*>): Boolean = conf is TestoConfiguration

override fun createCoverageEnabledConfiguration(conf: RunConfigurationBase<*>): CoverageEnabledConfiguration =
TestoCoverageEnabledConfiguration(conf as TestoRunConfiguration)
TestoCoverageEnabledConfiguration(conf)

override fun createCoverageSuite(
name: String,
Expand All @@ -103,7 +103,7 @@ class TestoCoverageEngine : CoverageEngine() {

override fun getCoverageAnnotator(project: Project): CoverageAnnotator = TestoCoverageAnnotator.getInstance(project)

override fun coverageEditorHighlightingApplicableTo(psiFile: PsiFile): Boolean = psiFile is PhpFile
override fun coverageEditorHighlightingApplicableTo(psiFile: PsiFile): Boolean = TestoPhp.getInstance().isPhpFile(psiFile)

override fun acceptedByFilters(psiFile: PsiFile, suite: CoverageSuitesBundle): Boolean = true

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import com.github.xepozz.testo.infection.TestoMutationService
import com.github.xepozz.testo.infection.mutationFilterFor
import com.github.xepozz.testo.runs.TestoRunStore
import com.github.xepozz.testo.runs.restoreTestoConfiguration
import com.github.xepozz.testo.tests.run.TestoRunConfiguration
import com.github.xepozz.testo.tests.run.TestoRunConfigurationType
import com.github.xepozz.testo.launch.TestoConfiguration
import com.github.xepozz.testo.php.TestoPhp
import com.intellij.coverage.CoverageDataManager
import com.intellij.coverage.CoverageSuitesBundle
import com.intellij.execution.RunManager
Expand Down Expand Up @@ -74,8 +74,8 @@ internal object TestoCoverageMutation {
val ready = TestoInfectionReports.readiness(runDir, manifest) as? TestoMutationReadiness.Ready ?: return null
val configuration = restoreTestoConfiguration(project, runDir, manifest)
val saved = RunManager.getInstance(project)
.findConfigurationByTypeAndName(TestoRunConfigurationType.INSTANCE, configuration.name)
?.configuration as? TestoRunConfiguration
.findConfigurationByTypeAndName(TestoPhp.getInstance().configurationFactory().type, configuration.name)
?.configuration as? TestoConfiguration
val sources = runCatching { TestoInfectionReports.coveredSourceFiles(ready.coverageXml) }.getOrDefault(emptyList())
val root = runCatching { TestoInfectionReports.coverageRoot(ready.coverageXml) }.getOrNull()
return Prepared(TestoMutationRecipe(configuration, runDir, ready, saved ?: configuration), sources, root)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
package com.github.xepozz.testo.coverage

import com.github.xepozz.testo.php.PhpToolLauncher
import com.github.xepozz.testo.tests.run.TestoRunConfiguration
import com.github.xepozz.testo.tests.run.TestoRunConfigurationType
import com.intellij.execution.RunManager
import com.github.xepozz.testo.php.TestoPhp
import com.intellij.openapi.project.Project
import com.jetbrains.php.config.PhpProjectConfigurationFacade
import com.jetbrains.php.config.interpreters.PhpInterpreter

/**
* [path] as it exists on this machine: as is, else through the first of [toLocal] that lands on an existing file.
Expand All @@ -18,18 +13,6 @@ fun resolveCoverageSourcePath(path: String, toLocal: List<(String) -> String?>,
?: path
}

/**
* The remote → local translations a report's source paths may need: a report written in a container or WSL names
* files by the interpreter's paths. Taken from the project rather than from the run, since a suite is also loaded from
* a manual import or after a restart, with no run around it.
*/
fun coverageSourcePathMappers(project: Project): List<(String) -> String?> {
val configured = RunManager.getInstance(project)
.getConfigurationsList(TestoRunConfigurationType.INSTANCE)
.mapNotNull { (it as? TestoRunConfiguration)?.interpreter }
val default = runCatching { PhpProjectConfigurationFacade.getInstance(project).interpreter }.getOrNull()
return (configured + listOfNotNull(default))
.filter(PhpInterpreter::isRemote)
.distinctBy { it.name }
.map { interpreter -> PhpToolLauncher(project, interpreter)::toLocal }
}
/** The interpreter → local translations a report's source paths may need — see [TestoPhp.coverageSourcePathMappers]. */
fun coverageSourcePathMappers(project: Project): List<(String) -> String?> =
TestoPhp.getInstance().coverageSourcePathMappers(project)
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
import com.github.xepozz.testo.coverage.perTest.TEST_ID_ORDER
import com.github.xepozz.testo.coverage.perTest.TestoCoveringTestsPopup
import com.github.xepozz.testo.coverage.perTest.testsCoveringElement
import com.github.xepozz.testo.php.PhpClassView
import com.github.xepozz.testo.php.PhpDeclarationView
import com.github.xepozz.testo.php.PhpFunctionView
import com.github.xepozz.testo.php.PhpLeafKind
import com.github.xepozz.testo.php.TestoPhp
import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer
import com.intellij.codeInsight.daemon.LineMarkerInfo
import com.intellij.codeInsight.daemon.LineMarkerProvider
Expand All @@ -13,12 +18,8 @@
import com.intellij.openapi.editor.markup.GutterIconRenderer
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiElement
import com.intellij.psi.util.elementType

Check warning on line 21 in src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoveringTestsLineMarkerProvider.kt

View workflow job for this annotation

GitHub Actions / Inspect code

Unused import directive

Unused import directive
import com.intellij.ui.awt.RelativePoint
import com.jetbrains.php.lang.lexer.PhpTokenTypes
import com.jetbrains.php.lang.psi.elements.Function
import com.jetbrains.php.lang.psi.elements.PhpClass
import com.jetbrains.php.lang.psi.elements.PhpNamedElement

/**
* A gutter icon on every method, function and class the per-test coverage recorded as covered: *Run covering tests (N)*,
Expand All @@ -30,20 +31,21 @@
class TestoCoveringTestsLineMarkerProvider : LineMarkerProvider {

override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? {
if (element.elementType != PhpTokenTypes.IDENTIFIER) return null
val php = TestoPhp.getInstance()
if (php.leafKind(element) != PhpLeafKind.IDENTIFIER) return null
val owner = element.parent
if (owner !is Function && owner !is PhpClass) return null
owner as PhpNamedElement
val declaration = php.view(owner) as? PhpDeclarationView ?: return null
if (declaration !is PhpFunctionView && declaration !is PhpClassView) return null
// The declaration's own name, and nothing else that parses as an identifier under it — otherwise one
// declaration can be marked twice.
if (owner.nameNode?.psi !== element) return null
if (declaration.nameIdentifier !== element) return null
val project = element.project
if (!TestoCoveringTestsGutter.getInstance(project).enabled) return null

val tests = testsCoveringElement(owner).sortedWith(TEST_ID_ORDER)
if (tests.isEmpty()) return null
val label = TestoBundle.message("testo.coverage.gutter.run.covering", tests.size)
val subject = owner.name
val subject = declaration.name

return LineMarkerInfo(
element,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
package com.github.xepozz.testo.coverage.perTest

import com.github.xepozz.testo.TestoBundle
import com.github.xepozz.testo.coverage.TestoCoverageProgramRunner
import com.github.xepozz.testo.coverage.format.TestId
import com.github.xepozz.testo.tests.run.TestoRunConfiguration
import com.github.xepozz.testo.tests.run.TestoRunConfigurationType
import com.github.xepozz.testo.launch.TestoConfiguration
import com.github.xepozz.testo.launch.TestoScope
import com.github.xepozz.testo.php.TestoPhp
import com.intellij.execution.ExecutionManager
import com.intellij.execution.ExecutorRegistry
import com.intellij.execution.RunManager
import com.intellij.execution.runners.ExecutionEnvironmentBuilder
import com.intellij.openapi.project.Project
import com.jetbrains.php.testFramework.run.PhpTestRunnerSettings

/**
* Runs a set of tests read off the per-test coverage — the covering tests of a line, a declaration, a file or a whole
Expand All @@ -27,18 +26,19 @@ internal object TestoCoveringTestsLauncher {
project: Project,
tests: Collection<TestId>,
name: String,
executorId: String = TestoCoverageProgramRunner.EXECUTOR_ID,
executorId: String = TestoConfiguration.COVERAGE_EXECUTOR_ID,
) {
val mapper = TestoTestIdentityMapper.getInstance()
val filters = tests.map { mapper.toFilterSelector(it) }.distinct().sorted()
if (filters.isEmpty()) return
val executor = ExecutorRegistry.getInstance().getExecutorById(executorId) ?: return

val settings = RunManager.getInstance(project).createConfiguration(name, TestoRunConfigurationType.INSTANCE)
val configuration = settings.configuration as? TestoRunConfiguration ?: return
configuration.testoSettings.getTestoRunnerSettings().apply {
val factory = TestoPhp.getInstance().configurationFactory()
val settings = RunManager.getInstance(project).createConfiguration(name, factory)
val configuration = settings.configuration as? TestoConfiguration ?: return
configuration.selection = configuration.selection.apply {
rerunFilters = filters
scope = PhpTestRunnerSettings.Scope.ConfigurationFile
scope = TestoScope.CONFIGURATION_FILE
}
val environment = ExecutionEnvironmentBuilder.createOrNull(executor, settings)?.build() ?: return
ExecutionManager.getInstance(project).restartRunProfile(environment)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
package com.github.xepozz.testo.coverage.perTest

import com.github.xepozz.testo.coverage.format.TestId
import com.github.xepozz.testo.php.PhpFunctionView
import com.github.xepozz.testo.php.TestoPhp
import com.github.xepozz.testo.tests.TestoTestRunLineMarkerProvider
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.application.ReadAction
import com.intellij.openapi.project.Project
import com.intellij.pom.Navigatable
import com.intellij.util.concurrency.AppExecutorUtil
import com.intellij.psi.PsiElement
import com.jetbrains.php.PhpIndex
import com.jetbrains.php.lang.psi.elements.Method

/**
* The one place that maps a coverage [TestId] (a `\`-qualified class + method, as coverage-xml spells covering tests)
* onto Testo's own identities — so its consumers cannot diverge. A `--filter` selector is a pure string (available
* with no PSI); the `php_qn://` hint and PSI need the class resolved through [PhpIndex].
* with no PSI); the `php_qn://` hint and PSI need the class resolved through the class index.
*/
interface TestoTestIdentityMapper {
/** `\Ns\FooTest::method` — the selector Testo's `--filter` accepts (matches TestoRunTarget.filterOf output). */
Expand Down Expand Up @@ -44,12 +44,14 @@ fun navigateToTest(project: Project, id: TestId) {
internal object DefaultTestIdentityMapper : TestoTestIdentityMapper {
override fun toFilterSelector(id: TestId): String = "\\" + id.fqcn.trimStart('\\') + "::" + id.method

override fun toLocationHint(id: TestId, project: Project): String? =
(resolve(id, project) as? Method)?.let { TestoTestRunLineMarkerProvider.getLocationHint(it) }
override fun toLocationHint(id: TestId, project: Project): String? {
val method = TestoPhp.getInstance().view(resolve(id, project)) as? PhpFunctionView ?: return null
return if (method.isMethod) TestoTestRunLineMarkerProvider.getLocationHint(method) else null
}

override fun resolve(id: TestId, project: Project): PsiElement? {
val fqn = "\\" + id.fqcn.trimStart('\\')
return PhpIndex.getInstance(project).getClassesByFQN(fqn)
.firstNotNullOfOrNull { it.findMethodByName(id.method) }
return TestoPhp.getInstance().classesByFqn(project, fqn)
.firstNotNullOfOrNull { it.findMethod(id.method)?.psi }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.github.xepozz.testo.index

import com.intellij.util.indexing.FileBasedIndex

/**
* PHP files, recognised by the file type's name rather than its class: every PHP plugin registers its own file type
* class, and all of them name it "PHP" (the plugin's own `fileType name="PHP"` registration relies on that too).
*/
internal val PHP_INPUT_FILTER = FileBasedIndex.InputFilter { it.fileType.name == "PHP" }
Loading
Loading