From a3d70459ffad0f18cb2a986e040ea17b8aa27d85 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 | 97 ++++++++++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 24 deletions(-) diff --git a/languages/php/runnables.scm b/languages/php/runnables.scm index 183cf88..35f3379 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 ( @@ -9,6 +13,7 @@ . name: (_) @_name (#match? @_name ".*Test$") + (base_clause) body: (declaration_list (method_declaration (visibility_modifier)? @_visibility @@ -23,6 +28,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 ( @@ -32,6 +39,7 @@ . name: (_) @_name (#match? @_name ".*Test$") + (base_clause) body: (declaration_list ((comment) @_comment (#match? @_comment ".*@test\\b.*") @@ -47,37 +55,62 @@ (#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 +; 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. ( - (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.*") - ) - ) + (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 @@ -85,6 +118,22 @@ . name: (_) @run (#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) ) From 94b669e24125b6e02a14a38f743bfb7973a9abe6 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 | 145 ++++++++++++++++++++++++++++++++++++ languages/php/tasks.json | 24 ++++++ 2 files changed, 169 insertions(+) diff --git a/languages/php/runnables.scm b/languages/php/runnables.scm index 35f3379..4f4a019 100644 --- a/languages/php/runnables.scm +++ b/languages/php/runnables.scm @@ -138,6 +138,151 @@ (#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 ( 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 31055677ed74b4a226330750df85156e0b0339a0 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 | 270 +++++++++++++++++++----------------- languages/php/tasks.json | 24 ++++ 2 files changed, 169 insertions(+), 125 deletions(-) diff --git a/languages/php/runnables.scm b/languages/php/runnables.scm index 4f4a019..184d293 100644 --- a/languages/php/runnables.scm +++ b/languages/php/runnables.scm @@ -55,57 +55,16 @@ (#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 @@ -148,14 +107,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 @@ -198,89 +154,153 @@ (#set! tag testo-test) ) -; Free function annotated with a #[Test] attribute +; 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. ( - (function_definition - attributes: (attribute_list - (attribute_group - (attribute [(name) (qualified_name)] @_fn_attr) - ) - ) - (#any-of? @_fn_attr "Test" "\\Testo\\Test") - name: (_) @run - ) @_testo-test + [ + (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) ) -; 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. +; 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. ; -; Form 1: no namespace, or the `namespace X;` (semicolon) form — the `use` and -; the class are siblings under the program root. +; 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. ( - (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) + [ + (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) ) -; Form 2: the braced `namespace X { ... }` form — the `use` and the class live -; inside the namespace body instead of at the program root. +; #[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. ( - (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_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) ) -; 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. +; #[Bench] (\Testo\Bench) -> --type=bench. ( - (method_declaration + [ + (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 (qualified_name) @_attr))) - (#eq? @_attr "\\Testo\\Test") - name: (_) @run - ) @_testo-test + (attribute_group (attribute [(name) (qualified_name)] @run))) + (#any-of? @run + "TestRectorFixtures" + "\\Testo\\Bridge\\Rector\\Testing\\TestRectorFixtures") + ) @_testo-type-rector-fixture + (#set! tag testo-type-rector-fixture) +) + +; 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) ) -; Testo configuration file: `return new ApplicationConfig(...)`. Runs the whole -; suite defined by this config via `testo --config=`. +; Run-all icon on the class name for a #[TestRectorFixtures] rule class (the +; class-level counterpart of the run-all pattern above). ( - (return_statement - (object_creation_expression - [(name) (qualified_name)] @run - (#match? @run "(^|\\\\)ApplicationConfig$")) - ) @_testo-config - (#set! tag testo-config) + (class_declaration + attributes: (attribute_list + (attribute_group (attribute [(name) (qualified_name)] @_attr))) + (#any-of? @_attr + "TestRectorFixtures" + "\\Testo\\Bridge\\Rector\\Testing\\TestRectorFixtures") + name: (_) @run + ) @_testo-test + (#set! tag testo-test) ) ; Add support for Pest runnable 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 91ce55f8c46f443add1a3db960f0e26067a9ab84 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 | 47 +++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/languages/php/runnables.scm b/languages/php/runnables.scm index 184d293..54e9e6b 100644 --- a/languages/php/runnables.scm +++ b/languages/php/runnables.scm @@ -154,17 +154,34 @@ (#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))) + (attribute_group (attribute (qualified_name) @_attr))) name: (_) @run) (function_definition attributes: (attribute_list @@ -175,6 +192,22 @@ (#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=`. ( From cbd4a7e2561c78eb930215b05345c996c1fb6cc1 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 | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/languages/php/runnables.scm b/languages/php/runnables.scm index 54e9e6b..bc0e638 100644 --- a/languages/php/runnables.scm +++ b/languages/php/runnables.scm @@ -198,13 +198,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. +; +; 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. ( - (method_declaration - attributes: (attribute_list - (attribute_group (attribute (name) @_attr))) - (#eq? @_attr "Test") - name: (_) @run - ) @_phpunit-test + (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) ) From 707c142bf559fbe61c38a3e01fe7016c2923fd0b 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 bc0e638..cdb5449 100644 --- a/languages/php/runnables.scm +++ b/languages/php/runnables.scm @@ -118,7 +118,10 @@ ; 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 @@ -136,8 +139,8 @@ (#any-of? @_rtype "void" "never") ) ) - ) @_testo-test - (#set! tag testo-test) + ) @_testo-type-test + (#set! tag testo-type-test) ) ; Class annotated with a class-level #[Test] attribute (run the whole case)