Skip to content

Add Testo test framework support; improve PHPUnit runnable detection#127

Open
roxblnfk wants to merge 6 commits into
zed-extensions:mainfrom
roxblnfk:testo-runnables
Open

Add Testo test framework support; improve PHPUnit runnable detection#127
roxblnfk wants to merge 6 commits into
zed-extensions:mainfrom
roxblnfk:testo-runnables

Conversation

@roxblnfk

@roxblnfk roxblnfk commented Jul 2, 2026

Copy link
Copy Markdown

Added: Testo support

Runnables and tasks for Testo, which marks tests with attributes rather than by naming convention:

  • class-level #[Test] → run button on the class and on each public void/never method (other return types are data providers, skipped);
  • a class whose #[Test] is on the methods (not on the class) → run button on the class name too, so the whole class can be run at once;
  • free functions with #[Test];
  • per-attribute "run this kind" buttons, placed on the attribute itself: #[Test]--type=test, #[TestInline]--type=inline, #[Bench]--type=bench, #[TestRectorFixtures] (class-level) → --type=rector-fixture;
  • config files (return new ApplicationConfig(...)).

Tasks run php vendor/bin/testo for a symbol / file / whole suite / config / kind.

Changed: PHPUnit detection

  • A *Test class with no extends is no longer treated as PHPUnit. PHPUnit tests always extend TestCase, so the name-based patterns now require a base_clause — this stops false buttons on non-PHPUnit *Test classes (e.g. Testo tests).
  • The fully-qualified #[\PHPUnit\Framework\Attributes\Test] now gets a gutter button.
  • A bare method-level #[Test] runs PHPUnit from the method-name row, but only when the class extends — PHPUnit tests always extend TestCase, so a bare #[Test] in a class with no parent is left to Testo (see the note below).

Note: the ambiguous bare #[Test]

A bare #[Test] is shared by Testo and PHPUnit, and the only exact way to tell them apart — the file's use import — requires a program-rooted query that exhausts tree-sitter's match limit and makes gutters vanish on real files (details in the comment below). So instead of correlating the import, the two frameworks are split by gutter position: the attribute row runs it as Testo, and the method-name row runs it as PHPUnit — the latter only when the class extends something (PHPUnit always inherits TestCase). A bare #[Test] in a class with no parent stays Testo. Fully-qualified #[\Testo\Test] and free functions are unambiguously Testo.

Tests

Runnable-query tests for this extension are proposed separately in #129. They cover the existing PHPUnit/Pest queries and document, as #[ignore]d tests, the exact over-tagging cases this PR fixes (a *Test class with no inheritance, and a bare #[Test] in such a class). If #129 is merged first, I'll add tests covering the Testo runnables introduced here and flip those documented-gap cases to passing.

roxblnfk added 2 commits July 2, 2026 13:32
- Require a base_clause on the name-convention (*Test) patterns: a PHPUnit
  test class always extends TestCase (directly or transitively), so a *Test
  class with no `extends` is not PHPUnit and is no longer tagged phpunit-test
  (it is most likely a test from another framework).
- Disambiguate method-level #[Test] by the imported attribute
  (PHPUnit\Framework\Attributes\Test) via the file's `use`, and by a
  fully-qualified #[\PHPUnit\Framework\Attributes\Test], instead of the class
  name — so it no longer misfires on another framework's method-level #[Test].
  This supersedes zed-extensions#121, which matched any attribute whose last name segment is
  "Test" (and would also tag e.g. #[\Testo\Test] or #[\App\Test] as PHPUnit).
Testo (https://php-testo.github.io) marks tests with the #[Test] attribute
instead of naming/inheritance conventions.

runnables.scm (tag `testo-test`):
- class-level #[Test] -> the class plus every public void/never method
  (other return types are data providers and are skipped);
- free functions annotated with #[Test];
- method-level #[Test], disambiguated from PHPUnit by `use Testo\Test`
  (PHP forbids two imports sharing an alias) or a fully-qualified
  #[\Testo\Test];
- `return new ApplicationConfig(...)` config files (tag `testo-config`).

tasks.json: run a symbol/file/whole suite via `php vendor/bin/testo`
(--filter/--path), and run a config via --config.
@cla-bot

cla-bot Bot commented Jul 2, 2026

Copy link
Copy Markdown

We require contributors to sign our Contributor License Agreement, and we don't have @roxblnfk on file. You can sign our CLA at https://zed.dev/cla. Once you've signed, post a comment here that says '@cla-bot check'.

@roxblnfk

roxblnfk commented Jul 2, 2026

Copy link
Copy Markdown
Author

@cla-bot check

@cla-bot cla-bot Bot added the cla-signed label Jul 2, 2026
@cla-bot

cla-bot Bot commented Jul 2, 2026

Copy link
Copy Markdown

The cla-bot has been summoned, and re-checked this pull request!

roxblnfk added 2 commits July 2, 2026 18:23
Per-attribute type runnables (gutter icon on the attribute, running only
that kind via `--type=`), for methods and free functions alike:
  * #[Test] / #[\Testo\Test]                 -> --type=test
  * #[TestInline]                            -> --type=inline (repeatable)
  * #[Bench]                                 -> --type=bench
  * #[TestRectorFixtures] (class-level)      -> --type=rector-fixture
Each test symbol also keeps a "run all kinds" icon on its name (no
`--type`), and matching tasks were added to tasks.json.

Match #[Test] locally instead of via program-rooted `use` correlation.
Correlating a bare #[Test] with its `use Testo\Test` / `use PHPUnit\...\Test`
import required a query rooted at `program`/`namespace` spanning the whole
file; tree-sitter binds the `use` and the method independently, so it
materializes one in-progress match state per (use-statement x method) pair
(predicates only filter at completion, so the states pile up even when the
pattern matches nothing). On real files this exhausts the query match limit
and later runnables are dropped -- gutters vanish from some line downward,
triggered by a second attribute_group or by many `use` lines. All patterns
are now rooted at the declaration (method/function/class), so each match is
O(1) states.

Consequence: a bare #[Test] can no longer be attributed by syntax alone, so
it is treated as Testo; PHPUnit keeps its naming-convention detection and the
fully-qualified #[\PHPUnit\Framework\Attributes\Test].
For a bare method-level `#[Test]` (ambiguous between Testo and PHPUnit,
with no local way to tell them apart), stop guessing and split the two
frameworks by gutter position: the Testo run stays on the attribute row
(the `--type=test` icon), and the method-name row now runs PHPUnit. The
user picks a framework by where they click. Fully-qualified `#[\Testo\Test]`
and free functions remain unambiguously Testo on the name.

Also add a run-all gutter on the class name for a class whose `#[Test]`
lives on the methods rather than on the class, so the whole class can be
run at once. Rooted at the class (never at `program`), so it stays linear
in the number of methods and does not reintroduce the match-state blow-up;
it fires once per matching method but those collapse to one indicator by row.
@roxblnfk

roxblnfk commented Jul 2, 2026

Copy link
Copy Markdown
Author

Update: reworked the #[Test] disambiguation — the use-import approach breaks gutters

Heads-up on the method-level #[Test] handling described above (telling Testo from PHPUnit via the use import). It turned out to be unshippable, so I changed the approach.

Bug
On real files the run gutters silently vanish from some line downward; everything above still works. Two things reproduce it: adding a second attribute to a method (#[Test] + #[Repeat]), or having many use statements — removing half of them brings the gutters back.

Why
Correlating a bare #[Test] with its use import needs a query rooted at program, spanning the whole file. tree-sitter then tracks one in-progress match state per (use-statement × method) pair — which exhausts the query match limit and drops later runnables. (I verified against the pinned tree-sitter-php grammar that the raw matches are correct, so it's match-handling, not parsing.)

Fix
Match #[Test] and the other attributes locally — rooted at the method / function / class node, never at program — so each match is O(1) states and no file-spanning correlation remains.

Handling the ambiguity
Without the use correlation a bare #[Test] can't be attributed to a framework from syntax alone. Instead of guessing, I split the two frameworks by gutter position:

  • the attribute row (#[Test]) runs it as Testo;
  • the method-name row runs it as PHPUnit.

So the user chooses a framework by where they click. Fully-qualified #[\Testo\Test] and free functions stay unambiguously Testo.

image

The two can also be combined onto a single gutter (both run options in one menu) — screenshot below. Happy to go that way instead if maintainers prefer; let me know.

image

PHPUnit tests always extend `TestCase`, so a bare `#[Test]` in a class with
no `extends` is not PHPUnit. Gate the method-name PHPUnit runnable on a
`base_clause`: an inheriting class still gets the PHPUnit run on the method
name, while a bare `#[Test]` in a non-inheriting class (a Testo test) keeps
only its Testo attribute runnable and a clean method-name row.

The rule is rooted at the class (like the naming-convention PHPUnit patterns),
so it stays linear -- the base_clause is a single node, not a per-`use`
multiplier, and does not reintroduce the match-state blow-up.
@roxblnfk

roxblnfk commented Jul 2, 2026

Copy link
Copy Markdown
Author

Follow-up: only offer PHPUnit on a bare #[Test] when the class has a parent

Refined the split from the previous comment.

PHPUnit tests always extend TestCase, so a bare #[Test] in a class with no extends isn't PHPUnit. The method-name PHPUnit run is now gated on the class having a base_clause:

  • bare #[Test] in an inheriting class → method-name row runs PHPUnit (as before);
  • bare #[Test] in a class with no parent (a Testo test) → no PHPUnit button; the row stays empty, and the test keeps its Testo attribute button plus the class-level run button.

Same shape as the naming-convention PHPUnit patterns (rooted at the class), so it stays linear and doesn't bring back the match-limit blow-up.

… class

A public void/never method in a class-level #[Test] class is a test case, so
tag it `testo-type-test` (the single "run this test" action, same as a
method-level #[Test] attribute) instead of `testo-test`. The run/file/all menu
now lives only on the class name, consistent with how the method-level
attribute already behaves.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants