From a7b4865fc85e22881fad2a03f456feb96add6bdc Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 2 Jul 2026 13:28:25 +0400 Subject: [PATCH 1/6] Improve PHPUnit runnable detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 #121, which matched any attribute whose last name segment is "Test" (and would also tag e.g. #[\Testo\Test] or #[\App\Test] as PHPUnit). --- languages/php/runnables.scm | 95 ++++++++++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 22 deletions(-) diff --git a/languages/php/runnables.scm b/languages/php/runnables.scm index 704bcf3..764506d 100644 --- a/languages/php/runnables.scm +++ b/languages/php/runnables.scm @@ -1,5 +1,9 @@ ; Class that follow the naming convention of PHPUnit test classes ; and that doesn't have the abstract modifier +; and extends a base class (PHPUnit test classes always inherit from TestCase, +; directly or transitively; a *Test class with no `extends` at all is not +; PHPUnit — most likely a Testo test — so requiring a base_clause avoids +; tagging those as phpunit-test) ; and have a method that follow the naming convention of PHPUnit test methods ; and the method is public ((class_declaration @@ -8,6 +12,7 @@ . name: (_) @_name (#match? @_name ".*Test$") + (base_clause) body: (declaration_list (method_declaration (visibility_modifier)? @_visibility @@ -18,6 +23,8 @@ ; Class that follow the naming convention of PHPUnit test classes ; and that doesn't have the abstract modifier +; and extends a base class (see note above — filters out inheritance-less +; Testo classes) ; and have a method that has the @test annotation ; and the method is public ((class_declaration @@ -26,6 +33,7 @@ . name: (_) @_name (#match? @_name ".*Test$") + (base_clause) body: (declaration_list ((comment) @_comment (#match? @_comment ".*@test\\b.*") @@ -37,37 +45,80 @@ (#not-match? @run "^test.*"))))) @_phpunit-test (#set! tag phpunit-test)) -; Class that follow the naming convention of PHPUnit test classes -; and that doesn't have the abstract modifier -; and have a method that has the #[Test] attribute -; and the method is public -((class_declaration - (_)* @_modifier - (#not-any-eq? @_modifier "abstract") - . - name: (_) @_name - (#match? @_name ".*Test$") - body: (declaration_list - (method_declaration - (attribute_list - (attribute_group - (attribute - (name) @_attribute))) - (#eq? @_attribute "Test") - (visibility_modifier)? @_visibility - (#eq? @_visibility "public") - name: (_) @run - (#not-match? @run "^test.*")))) @_phpunit-test +; Method carrying the #[Test] attribute, disambiguated to PHPUnit via the +; file's `use` import. Both PHPUnit (PHPUnit\Framework\Attributes\Test) and +; Testo (Testo\Test) expose a method-level #[Test]; since PHP forbids two +; imports sharing an alias, the presence of `use PHPUnit\Framework\Attributes\Test` +; proves the attribute is PHPUnit's. This replaces the old class-name (*Test) +; gate so that a Testo file's #[Test] methods are no longer tagged phpunit-test. +; (Trade-off: a PHPUnit file importing the attribute via a group use or writing +; it fully-qualified won't match here — it still gets class-level buttons.) +; +; Form 1: no namespace, or `namespace X;` — `use` and class are siblings. +((program + (namespace_use_declaration + (namespace_use_clause + (qualified_name) @_use)) + (#eq? @_use "PHPUnit\\Framework\\Attributes\\Test") + (class_declaration + body: (declaration_list + (method_declaration + attributes: (attribute_list + (attribute_group + (attribute + (name) @_attribute))) + (#eq? @_attribute "Test") + (visibility_modifier)? @_visibility + (#eq? @_visibility "public") + name: (_) @run + (#not-match? @run "^test.*"))))) @_phpunit-test + (#set! tag phpunit-test)) + +; Form 2: braced `namespace X { ... }`. +((namespace_definition + body: (compound_statement + (namespace_use_declaration + (namespace_use_clause + (qualified_name) @_use)) + (#eq? @_use "PHPUnit\\Framework\\Attributes\\Test") + (class_declaration + body: (declaration_list + (method_declaration + attributes: (attribute_list + (attribute_group + (attribute + (name) @_attribute))) + (#eq? @_attribute "Test") + (visibility_modifier)? @_visibility + (#eq? @_visibility "public") + name: (_) @run + (#not-match? @run "^test.*")))))) @_phpunit-test (#set! tag phpunit-test)) ; Class that follow the naming convention of PHPUnit test classes ; and that doesn't have the abstract modifier +; and extends a base class (see note above — filters out inheritance-less +; Testo classes) ((class_declaration (_)* @_modifier (#not-any-eq? @_modifier "abstract") . name: (_) @run - (#match? @run ".*Test$")) @_phpunit-test + (#match? @run ".*Test$") + (base_clause)) @_phpunit-test + (#set! tag phpunit-test)) + +; Method carrying a fully-qualified `#[\PHPUnit\Framework\Attributes\Test]` +; attribute — self-identifying, so no `use` correlation is needed. +((method_declaration + attributes: (attribute_list + (attribute_group + (attribute + (qualified_name) @_attribute))) + (#eq? @_attribute "\\PHPUnit\\Framework\\Attributes\\Test") + (visibility_modifier)? @_visibility + (#eq? @_visibility "public") + name: (_) @run) @_phpunit-test (#set! tag phpunit-test)) ; Add support for Pest runnable From c77fc0329dd6f4e6815c1fe26c2d3ad2ca0266a4 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 2 Jul 2026 13:28:47 +0400 Subject: [PATCH 2/6] Add Testo test framework runnables and tasks 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. --- languages/php/runnables.scm | 135 ++++++++++++++++++++++++++++++++++++ languages/php/tasks.json | 24 +++++++ 2 files changed, 159 insertions(+) diff --git a/languages/php/runnables.scm b/languages/php/runnables.scm index 764506d..530036d 100644 --- a/languages/php/runnables.scm +++ b/languages/php/runnables.scm @@ -121,6 +121,141 @@ name: (_) @run) @_phpunit-test (#set! tag phpunit-test)) +; --------------------------------------------------------------------------- +; Testo (https://php-testo.github.io) runnables. +; +; Testo detects tests by the `#[Test]` attribute rather than by naming +; convention: +; * a class annotated with a class-level `#[Test]` — every public method +; whose return type is `void`/`never` is a test case (other return types +; are treated as data providers and skipped); +; * any free function annotated with `#[Test]`. +; +; The class-level `#[Test]` is what distinguishes Testo from PHPUnit, where +; `#[Test]` (PHPUnit\Framework\Attributes\Test) is only ever placed on +; methods. A bare method-level `#[Test]` is therefore ambiguous between the +; two frameworks — tree-sitter can't tell `use Testo\Test` from +; `use PHPUnit\Framework\Attributes\Test` — so we deliberately do NOT emit a +; Testo runnable for it (that method-only Testo style is rare, and matching it +; would put Testo buttons on every PHPUnit test). A class-level or +; function-level `#[Test]` is unambiguous and is matched below. +; +; Note: abstract classes are not excluded here (tree-sitter queries can't +; assert the absence of a modifier). Testo ignores them at run time, so at +; worst a button on an abstract class runs and finds no cases. +; --------------------------------------------------------------------------- +; Public `void`/`never` method inside a class annotated with class-level #[Test] +((class_declaration + attributes: (attribute_list + (attribute_group + (attribute + [ + (name) + (qualified_name) + ] @_class_attr))) + (#any-of? @_class_attr "Test" "\\Testo\\Test") + body: (declaration_list + (method_declaration + (visibility_modifier) @_visibility + (#eq? @_visibility "public") + name: (_) @run + return_type: (_) @_rtype + (#any-of? @_rtype "void" "never")))) @_testo-test + (#set! tag testo-test)) + +; Class annotated with a class-level #[Test] attribute (run the whole case) +((class_declaration + attributes: (attribute_list + (attribute_group + (attribute + [ + (name) + (qualified_name) + ] @_class_attr))) + (#any-of? @_class_attr "Test" "\\Testo\\Test") + name: (_) @run) @_testo-test + (#set! tag testo-test)) + +; Free function annotated with a #[Test] attribute +((function_definition + attributes: (attribute_list + (attribute_group + (attribute + [ + (name) + (qualified_name) + ] @_fn_attr))) + (#any-of? @_fn_attr "Test" "\\Testo\\Test") + name: (_) @run) @_testo-test + (#set! tag testo-test)) + +; Method-level #[Test] disambiguated to Testo via the file's `use` import. +; PHP forbids importing two different classes under the same alias, so once a +; file contains `use Testo\Test;` every unqualified `#[Test]` in it is Testo's +; — that is how we tell a method-level Testo test apart from a PHPUnit one +; (`PHPUnit\Framework\Attributes\Test`) without semantic resolution. The `use` +; and the class have to be matched through a shared ancestor. +; +; Form 1: no namespace, or the `namespace X;` (semicolon) form — the `use` and +; the class are siblings under the program root. +((program + (namespace_use_declaration + (namespace_use_clause + (qualified_name) @_use)) + (#match? @_use "^Testo\\\\Test$") + (class_declaration + body: (declaration_list + (method_declaration + attributes: (attribute_list + (attribute_group + (attribute + (name) @_attr))) + (#eq? @_attr "Test") + name: (_) @run)))) @_testo-test + (#set! tag testo-test)) + +; Form 2: the braced `namespace X { ... }` form — the `use` and the class live +; inside the namespace body instead of at the program root. +((namespace_definition + body: (compound_statement + (namespace_use_declaration + (namespace_use_clause + (qualified_name) @_use)) + (#match? @_use "^Testo\\\\Test$") + (class_declaration + body: (declaration_list + (method_declaration + attributes: (attribute_list + (attribute_group + (attribute + (name) @_attr))) + (#eq? @_attr "Test") + name: (_) @run))))) @_testo-test + (#set! tag testo-test)) + +; Method carrying a fully-qualified `#[\Testo\Test]` attribute. A fully +; qualified name is self-identifying, so no `use` correlation is needed and it +; is unambiguous regardless of the class name. +((method_declaration + attributes: (attribute_list + (attribute_group + (attribute + (qualified_name) @_attr))) + (#eq? @_attr "\\Testo\\Test") + name: (_) @run) @_testo-test + (#set! tag testo-test)) + +; Testo configuration file: `return new ApplicationConfig(...)`. Runs the whole +; suite defined by this config via `testo --config=`. +((return_statement + (object_creation_expression + [ + (name) + (qualified_name) + ] @run + (#match? @run "(^|\\\\)ApplicationConfig$"))) @_testo-config + (#set! tag testo-config)) + ; Add support for Pest runnable ; Function expression that has `it`, `test` or `describe` as the function name ((function_call_expression diff --git a/languages/php/tasks.json b/languages/php/tasks.json index e2d1e7c..8e5eec2 100644 --- a/languages/php/tasks.json +++ b/languages/php/tasks.json @@ -35,6 +35,30 @@ "args": [], "tags": ["pest-test"] }, + { + "label": "testo: run $ZED_SYMBOL", + "command": "php", + "args": ["vendor/bin/testo", "--path=\"$ZED_RELATIVE_FILE\"", "--filter=\"$ZED_SYMBOL\""], + "tags": ["testo-test"] + }, + { + "label": "testo: run file $ZED_FILENAME", + "command": "php", + "args": ["vendor/bin/testo", "--path=\"$ZED_RELATIVE_FILE\""], + "tags": ["testo-test"] + }, + { + "label": "testo: run all tests", + "command": "php", + "args": ["vendor/bin/testo"], + "tags": ["testo-test"] + }, + { + "label": "testo: run suite ($ZED_FILENAME)", + "command": "php", + "args": ["vendor/bin/testo", "--config=\"$ZED_FILE\""], + "tags": ["testo-config"] + }, { "label": "execute selection $ZED_SELECTED_TEXT", "command": "php", From 0fc5bb872cd1927f98b0745b61e7f3ecc6f0ebcf Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 2 Jul 2026 18:23:16 +0400 Subject: [PATCH 3/6] Add per-attribute Testo runnables; match #[Test] locally 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]. --- languages/php/runnables.scm | 309 ++++++++++++++++++++++-------------- languages/php/tasks.json | 24 +++ 2 files changed, 210 insertions(+), 123 deletions(-) diff --git a/languages/php/runnables.scm b/languages/php/runnables.scm index 530036d..6d694c7 100644 --- a/languages/php/runnables.scm +++ b/languages/php/runnables.scm @@ -45,56 +45,16 @@ (#not-match? @run "^test.*"))))) @_phpunit-test (#set! tag phpunit-test)) -; Method carrying the #[Test] attribute, disambiguated to PHPUnit via the -; file's `use` import. Both PHPUnit (PHPUnit\Framework\Attributes\Test) and -; Testo (Testo\Test) expose a method-level #[Test]; since PHP forbids two -; imports sharing an alias, the presence of `use PHPUnit\Framework\Attributes\Test` -; proves the attribute is PHPUnit's. This replaces the old class-name (*Test) -; gate so that a Testo file's #[Test] methods are no longer tagged phpunit-test. -; (Trade-off: a PHPUnit file importing the attribute via a group use or writing -; it fully-qualified won't match here — it still gets class-level buttons.) -; -; Form 1: no namespace, or `namespace X;` — `use` and class are siblings. -((program - (namespace_use_declaration - (namespace_use_clause - (qualified_name) @_use)) - (#eq? @_use "PHPUnit\\Framework\\Attributes\\Test") - (class_declaration - body: (declaration_list - (method_declaration - attributes: (attribute_list - (attribute_group - (attribute - (name) @_attribute))) - (#eq? @_attribute "Test") - (visibility_modifier)? @_visibility - (#eq? @_visibility "public") - name: (_) @run - (#not-match? @run "^test.*"))))) @_phpunit-test - (#set! tag phpunit-test)) - -; Form 2: braced `namespace X { ... }`. -((namespace_definition - body: (compound_statement - (namespace_use_declaration - (namespace_use_clause - (qualified_name) @_use)) - (#eq? @_use "PHPUnit\\Framework\\Attributes\\Test") - (class_declaration - body: (declaration_list - (method_declaration - attributes: (attribute_list - (attribute_group - (attribute - (name) @_attribute))) - (#eq? @_attribute "Test") - (visibility_modifier)? @_visibility - (#eq? @_visibility "public") - name: (_) @run - (#not-match? @run "^test.*")))))) @_phpunit-test - (#set! tag phpunit-test)) - +; NOTE: a short method-level `#[Test]` is ambiguous between PHPUnit +; (PHPUnit\Framework\Attributes\Test) and Testo (Testo\Test). The only precise +; disambiguator is the file's `use` import, but correlating it with the method +; requires a query rooted at `program`/`namespace` that spans the whole file — +; such patterns create one in-progress match state per (use-statement × method) +; pair, which blows past tree-sitter's match limit on real files and silently +; drops later runnables (gutters vanish from some line downward). So bare +; `#[Test]` is handled locally by the Testo section instead; PHPUnit here relies +; on its naming convention (`*Test` class + `test*`/`@test`) and on the +; fully-qualified `#[\PHPUnit\Framework\Attributes\Test]` below. ; Class that follow the naming convention of PHPUnit test classes ; and that doesn't have the abstract modifier ; and extends a base class (see note above — filters out inheritance-less @@ -131,14 +91,11 @@ ; are treated as data providers and skipped); ; * any free function annotated with `#[Test]`. ; -; The class-level `#[Test]` is what distinguishes Testo from PHPUnit, where -; `#[Test]` (PHPUnit\Framework\Attributes\Test) is only ever placed on -; methods. A bare method-level `#[Test]` is therefore ambiguous between the -; two frameworks — tree-sitter can't tell `use Testo\Test` from -; `use PHPUnit\Framework\Attributes\Test` — so we deliberately do NOT emit a -; Testo runnable for it (that method-only Testo style is rare, and matching it -; would put Testo buttons on every PHPUnit test). A class-level or -; function-level `#[Test]` is unambiguous and is matched below. +; A bare method-level `#[Test]` is ambiguous between Testo and PHPUnit, and the +; only exact disambiguator (the file's `use` import) can only be correlated by a +; `program`-rooted query that blows past tree-sitter's match limit on real files +; (dropping later runnables). We therefore match `#[Test]` LOCALLY and treat it +; as Testo; PHPUnit keeps its naming-convention / fully-qualified detection. ; ; Note: abstract classes are not excluded here (tree-sitter queries can't ; assert the absence of a modifier). Testo ignores them at run time, so at @@ -176,86 +133,192 @@ name: (_) @run) @_testo-test (#set! tag testo-test)) -; Free function annotated with a #[Test] attribute -((function_definition +; Method or free function carrying `#[Test]` / `#[\Testo\Test]` — run-all icon on +; the name. Matched LOCALLY (rooted at the declaration), never at `program`, so +; there is no per-(use-statement × method) match-state blow-up. See the note in +; the PHPUnit section: a bare method-level `#[Test]` can't be told apart from +; PHPUnit's without a file-spanning `use` correlation, and that correlation is +; exactly what made gutters disappear — so bare `#[Test]` is treated as Testo. +([ + (method_declaration + attributes: (attribute_list + (attribute_group + (attribute + [ + (name) + (qualified_name) + ] @_attr))) + name: (_) @run) + (function_definition + attributes: (attribute_list + (attribute_group + (attribute + [ + (name) + (qualified_name) + ] @_attr))) + name: (_) @run) +] @_testo-test + (#any-of? @_attr "Test" "\\Testo\\Test") + (#set! tag testo-test)) + +; Testo configuration file: `return new ApplicationConfig(...)`. Runs the whole +; suite defined by this config via `testo --config=`. +((return_statement + (object_creation_expression + [ + (name) + (qualified_name) + ] @run + (#match? @run "(^|\\\\)ApplicationConfig$"))) @_testo-config + (#set! tag testo-config)) + +; --------------------------------------------------------------------------- +; Testo — typed attribute runnables (methods and free functions). +; +; Each test-kind attribute gets its own gutter icon anchored on the attribute +; itself, running only that kind via `--type=`. The icon sits on the +; attribute's row because `@run` is placed on the attribute node; `$ZED_SYMBOL` +; still resolves to the enclosing method/function (its outline item spans the +; attribute lines), so `--filter` stays symbol-scoped. Each kind matches both a +; `method_declaration` and a `function_definition` via a `[...]` alternation. +; +; The kinds have Testo-unique names and are matched by bare name or FQN +; directly, locally (never rooted at `program`, to avoid the match-state +; blow-up described in the PHPUnit section). `#[Test]` is treated as Testo. +; --------------------------------------------------------------------------- +; #[Test] / #[\Testo\Test] on a method or free function -> --type=test. +([ + (method_declaration + attributes: (attribute_list + (attribute_group + (attribute + [ + (name) + (qualified_name) + ] @run)))) + (function_definition + attributes: (attribute_list + (attribute_group + (attribute + [ + (name) + (qualified_name) + ] @run)))) +] @_testo-type-test + (#any-of? @run "Test" "\\Testo\\Test") + (#set! tag testo-type-test)) + +; #[TestInline] (\Testo\Inline\TestInline) -> --type=inline. Repeatable, so a +; symbol may carry several; each occurrence is a separate match and thus its own +; icon. The 0-based ordinal that Testo accepts as `--filter=:` cannot +; be derived by tree-sitter (it can't count filtered siblings, and Zed exposes +; no such variable), so the filter stays symbol-level and every inline case is +; run. +([ + (method_declaration + attributes: (attribute_list + (attribute_group + (attribute + [ + (name) + (qualified_name) + ] @run)))) + (function_definition + attributes: (attribute_list + (attribute_group + (attribute + [ + (name) + (qualified_name) + ] @run)))) +] @_testo-type-inline + (#any-of? @run "TestInline" "\\Testo\\Inline\\TestInline") + (#set! tag testo-type-inline)) + +; #[Bench] (\Testo\Bench) -> --type=bench. +([ + (method_declaration + attributes: (attribute_list + (attribute_group + (attribute + [ + (name) + (qualified_name) + ] @run)))) + (function_definition + attributes: (attribute_list + (attribute_group + (attribute + [ + (name) + (qualified_name) + ] @run)))) +] @_testo-type-bench + (#any-of? @run "Bench" "\\Testo\\Bench") + (#set! tag testo-type-bench)) + +; #[TestRectorFixtures(...)] (\Testo\Bridge\Rector\Testing\TestRectorFixtures) is +; a TARGET_CLASS attribute marking a Rector rule whose `*.php.inc` fixtures are the +; test cases -> --type=rector-fixture. Unlike the kinds above it sits on the class, +; so this matches a `class_declaration`; `$ZED_SYMBOL` resolves to the class name +; (the class outline item spans its attribute lines). The attribute's argument list +; (the fixture path) doesn't affect the match — `(name)` is still its first child. +((class_declaration attributes: (attribute_list (attribute_group (attribute [ (name) (qualified_name) - ] @_fn_attr))) - (#any-of? @_fn_attr "Test" "\\Testo\\Test") - name: (_) @run) @_testo-test - (#set! tag testo-test)) + ] @run))) + (#any-of? @run "TestRectorFixtures" "\\Testo\\Bridge\\Rector\\Testing\\TestRectorFixtures")) @_testo-type-rector-fixture + (#set! tag testo-type-rector-fixture)) -; Method-level #[Test] disambiguated to Testo via the file's `use` import. -; PHP forbids importing two different classes under the same alias, so once a -; file contains `use Testo\Test;` every unqualified `#[Test]` in it is Testo's -; — that is how we tell a method-level Testo test apart from a PHPUnit one -; (`PHPUnit\Framework\Attributes\Test`) without semantic resolution. The `use` -; and the class have to be matched through a shared ancestor. -; -; Form 1: no namespace, or the `namespace X;` (semicolon) form — the `use` and -; the class are siblings under the program root. -((program - (namespace_use_declaration - (namespace_use_clause - (qualified_name) @_use)) - (#match? @_use "^Testo\\\\Test$") - (class_declaration - body: (declaration_list - (method_declaration - attributes: (attribute_list - (attribute_group - (attribute - (name) @_attr))) - (#eq? @_attr "Test") - name: (_) @run)))) @_testo-test - (#set! tag testo-test)) - -; Form 2: the braced `namespace X { ... }` form — the `use` and the class live -; inside the namespace body instead of at the program root. -((namespace_definition - body: (compound_statement - (namespace_use_declaration - (namespace_use_clause - (qualified_name) @_use)) - (#match? @_use "^Testo\\\\Test$") - (class_declaration - body: (declaration_list - (method_declaration - attributes: (attribute_list - (attribute_group - (attribute - (name) @_attr))) - (#eq? @_attr "Test") - name: (_) @run))))) @_testo-test +; Run-all icon on the symbol name for the non-#[Test] kinds (method or free +; function): clicking it runs every case of the symbol, no `--type`. #[Test] +; symbols already get a run-all icon from the generic patterns above; this adds +; the same for symbols whose only marker is #[TestInline] / #[Bench] / +; #[RectorTestingPlugin]. It fires once per matching attribute, so a symbol with +; several (repeated or mixed kinds) yields duplicate matches on the same row — +; Zed keys runnables by row, collapsing them into a single gutter indicator. +([ + (method_declaration + attributes: (attribute_list + (attribute_group + (attribute + [ + (name) + (qualified_name) + ] @_attr))) + name: (_) @run) + (function_definition + attributes: (attribute_list + (attribute_group + (attribute + [ + (name) + (qualified_name) + ] @_attr))) + name: (_) @run) +] @_testo-test + (#any-of? @_attr "TestInline" "\\Testo\\Inline\\TestInline" "Bench" "\\Testo\\Bench") (#set! tag testo-test)) -; Method carrying a fully-qualified `#[\Testo\Test]` attribute. A fully -; qualified name is self-identifying, so no `use` correlation is needed and it -; is unambiguous regardless of the class name. -((method_declaration +; Run-all icon on the class name for a #[TestRectorFixtures] rule class (the +; class-level counterpart of the run-all pattern above). +((class_declaration attributes: (attribute_list (attribute_group (attribute - (qualified_name) @_attr))) - (#eq? @_attr "\\Testo\\Test") + [ + (name) + (qualified_name) + ] @_attr))) + (#any-of? @_attr "TestRectorFixtures" "\\Testo\\Bridge\\Rector\\Testing\\TestRectorFixtures") name: (_) @run) @_testo-test (#set! tag testo-test)) -; Testo configuration file: `return new ApplicationConfig(...)`. Runs the whole -; suite defined by this config via `testo --config=`. -((return_statement - (object_creation_expression - [ - (name) - (qualified_name) - ] @run - (#match? @run "(^|\\\\)ApplicationConfig$"))) @_testo-config - (#set! tag testo-config)) - ; Add support for Pest runnable ; Function expression that has `it`, `test` or `describe` as the function name ((function_call_expression diff --git a/languages/php/tasks.json b/languages/php/tasks.json index 8e5eec2..d985775 100644 --- a/languages/php/tasks.json +++ b/languages/php/tasks.json @@ -41,6 +41,30 @@ "args": ["vendor/bin/testo", "--path=\"$ZED_RELATIVE_FILE\"", "--filter=\"$ZED_SYMBOL\""], "tags": ["testo-test"] }, + { + "label": "testo: test $ZED_SYMBOL", + "command": "php", + "args": ["vendor/bin/testo", "--path=\"$ZED_RELATIVE_FILE\"", "--filter=\"$ZED_SYMBOL\"", "--type=test"], + "tags": ["testo-type-test"] + }, + { + "label": "testo: inline $ZED_SYMBOL", + "command": "php", + "args": ["vendor/bin/testo", "--path=\"$ZED_RELATIVE_FILE\"", "--filter=\"$ZED_SYMBOL\"", "--type=inline"], + "tags": ["testo-type-inline"] + }, + { + "label": "testo: bench $ZED_SYMBOL", + "command": "php", + "args": ["vendor/bin/testo", "--path=\"$ZED_RELATIVE_FILE\"", "--filter=\"$ZED_SYMBOL\"", "--type=bench"], + "tags": ["testo-type-bench"] + }, + { + "label": "testo: rector-fixture $ZED_SYMBOL", + "command": "php", + "args": ["vendor/bin/testo", "--path=\"$ZED_RELATIVE_FILE\"", "--filter=\"$ZED_SYMBOL\"", "--type=rector-fixture"], + "tags": ["testo-type-rector-fixture"] + }, { "label": "testo: run file $ZED_FILENAME", "command": "php", From 07aa0618fa4ff9d1ae1a4e9411d828aee92e8345 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 2 Jul 2026 18:23:16 +0400 Subject: [PATCH 4/6] Split ambiguous #[Test] by position; add class-level Testo gutter 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. --- languages/php/runnables.scm | 50 +++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/languages/php/runnables.scm b/languages/php/runnables.scm index 6d694c7..30d47d0 100644 --- a/languages/php/runnables.scm +++ b/languages/php/runnables.scm @@ -133,21 +133,36 @@ name: (_) @run) @_testo-test (#set! tag testo-test)) -; Method or free function carrying `#[Test]` / `#[\Testo\Test]` — run-all icon on -; the name. Matched LOCALLY (rooted at the declaration), never at `program`, so -; there is no per-(use-statement × method) match-state blow-up. See the note in -; the PHPUnit section: a bare method-level `#[Test]` can't be told apart from -; PHPUnit's without a file-spanning `use` correlation, and that correlation is -; exactly what made gutters disappear — so bare `#[Test]` is treated as Testo. +; Class whose #[Test] lives on the methods (not on the class) still gets a +; run-all gutter on the class name, so the whole class can be run at once. +; Rooted at the class (never at `program`), so it stays linear; it fires once +; per matching method, but those all land on the class-name row and Zed collapses +; them into a single indicator. +((class_declaration + name: (_) @run + body: (declaration_list + (method_declaration + attributes: (attribute_list + (attribute_group + (attribute + [ + (name) + (qualified_name) + ] @_attr))) + (#any-of? @_attr "Test" "\\Testo\\Test")))) @_testo-test + (#set! tag testo-test)) + +; Run-all icon on the name. Matched LOCALLY (rooted at the declaration), never at +; `program`, so there is no per-(use-statement × method) match-state blow-up. +; +; Unambiguously Testo: a fully-qualified `#[\Testo\Test]` (method or function), +; and a bare `#[Test]` on a free function (PHPUnit has no function tests). ([ (method_declaration attributes: (attribute_list (attribute_group (attribute - [ - (name) - (qualified_name) - ] @_attr))) + (qualified_name) @_attr))) name: (_) @run) (function_definition attributes: (attribute_list @@ -162,6 +177,21 @@ (#any-of? @_attr "Test" "\\Testo\\Test") (#set! tag testo-test)) +; A bare method-level `#[Test]` is ambiguous between Testo and PHPUnit (the only +; exact disambiguator is the file's `use` import, which can't be correlated +; without the `program`-rooted pattern that made gutters vanish). Instead of +; guessing, the ambiguity is split by position: the Testo run sits on the +; attribute row (the `testo-type-test` icon above, `--type=test`), and the +; method-name row gets the PHPUnit run — so the user picks by where they click. +((method_declaration + attributes: (attribute_list + (attribute_group + (attribute + (name) @_attr))) + (#eq? @_attr "Test") + name: (_) @run) @_phpunit-test + (#set! tag phpunit-test)) + ; Testo configuration file: `return new ApplicationConfig(...)`. Runs the whole ; suite defined by this config via `testo --config=`. ((return_statement From 97e069ef288ba593e1d00f6098438406653a2ff8 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 2 Jul 2026 18:46:41 +0400 Subject: [PATCH 5/6] Offer PHPUnit on a bare #[Test] method only when the class extends 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. --- languages/php/runnables.scm | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/languages/php/runnables.scm b/languages/php/runnables.scm index 30d47d0..7c907a3 100644 --- a/languages/php/runnables.scm +++ b/languages/php/runnables.scm @@ -183,13 +183,22 @@ ; guessing, the ambiguity is split by position: the Testo run sits on the ; attribute row (the `testo-type-test` icon above, `--type=test`), and the ; method-name row gets the PHPUnit run — so the user picks by where they click. -((method_declaration - attributes: (attribute_list - (attribute_group - (attribute - (name) @_attr))) - (#eq? @_attr "Test") - name: (_) @run) @_phpunit-test +; +; The PHPUnit run is only offered when the class extends something: PHPUnit tests +; always extend `TestCase`, so a bare `#[Test]` in a class with no `extends` is +; not PHPUnit and its method-name row stays empty. Rooted at the class (like the +; naming-convention PHPUnit patterns above), so it stays linear — the `base_clause` +; is a single node, not a per-`use` multiplier. +((class_declaration + (base_clause) + body: (declaration_list + (method_declaration + attributes: (attribute_list + (attribute_group + (attribute + (name) @_attr))) + (#eq? @_attr "Test") + name: (_) @run))) @_phpunit-test (#set! tag phpunit-test)) ; Testo configuration file: `return new ApplicationConfig(...)`. Runs the whole From f5853408d27f3e802cd88c86cb0992adb75f8458 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 2 Jul 2026 19:02:13 +0400 Subject: [PATCH 6/6] Give class-level #[Test] methods the single run, keep the menu on the 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. --- languages/php/runnables.scm | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/languages/php/runnables.scm b/languages/php/runnables.scm index 7c907a3..61ec265 100644 --- a/languages/php/runnables.scm +++ b/languages/php/runnables.scm @@ -101,7 +101,10 @@ ; assert the absence of a modifier). Testo ignores them at run time, so at ; worst a button on an abstract class runs and finds no cases. ; --------------------------------------------------------------------------- -; Public `void`/`never` method inside a class annotated with class-level #[Test] +; Public `void`/`never` method inside a class annotated with class-level #[Test]. +; These are test cases, so each gets the single "run this test" action +; (`testo-type-test`, like a method-level `#[Test]` attribute) rather than the +; run/file/all menu — that menu lives on the class name below. ((class_declaration attributes: (attribute_list (attribute_group @@ -117,8 +120,8 @@ (#eq? @_visibility "public") name: (_) @run return_type: (_) @_rtype - (#any-of? @_rtype "void" "never")))) @_testo-test - (#set! tag testo-test)) + (#any-of? @_rtype "void" "never")))) @_testo-type-test + (#set! tag testo-type-test)) ; Class annotated with a class-level #[Test] attribute (run the whole case) ((class_declaration