Skip to content
Merged
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
41 changes: 41 additions & 0 deletions internal/indexer/indexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -7885,6 +7885,17 @@ func (idx *Indexer) extractExternalModules() {
parse: modules.ParsePomXML,
ownPathFromSrc: readPomXMLOwnName,
},
{
path: "composer.json",
parse: modules.ParseComposerJSON,
ownPathFromSrc: modules.ComposerOwnName,
},
{
path: "composer.lock",
parse: modules.ParseComposerLock,
// The lockfile has no own-name notion separate from composer.json.
ownPathFromSrc: nil,
},
}

for _, m := range manifests {
Expand Down Expand Up @@ -7936,6 +7947,15 @@ func (idx *Indexer) extractOneModuleManifestSource(
FilePath: relPath,
Language: manifestLanguage(relPath),
}
// composer.json's autoload map is the only statement a PHP repo makes
// about which namespaces are its own. Carried on the manifest node so the
// resolver can tell a first-party `use` from a dependency's without
// re-reading the file.
if filepath.Base(relPath) == "composer.json" {
if roots := modules.ComposerAutoloadRoots(src); len(roots) > 0 {
manifestNode.Meta = map[string]any{"php_autoload_roots": encodeComposerAutoloadRoots(roots)}
}
}
allNodes := append([]*graph.Node{manifestNode}, nodes...)
idx.applyRepoPrefix(allNodes, edges)
idx.graph.AddBatch(allNodes, edges)
Expand Down Expand Up @@ -7966,6 +7986,22 @@ func (idx *Indexer) extractOneModuleManifestSource(
modules.LinkImportsIn(idx.graph, importNodes, specs, ownModulePath)
}

// encodeComposerAutoloadRoots flattens the PSR-4 / PSR-0 map into the
// `Prefix=>dir,dir;Prefix=>dir` form graph Meta can hold (Meta is gob-encoded,
// so a nested map costs a codec entry per shape). Sorted for determinism.
func encodeComposerAutoloadRoots(roots map[string][]string) string {
prefixes := make([]string, 0, len(roots))
for p := range roots {
prefixes = append(prefixes, p)
}
sort.Strings(prefixes)
parts := make([]string, 0, len(prefixes))
for _, p := range prefixes {
parts = append(parts, p+"=>"+strings.Join(roots[p], ","))
}
return strings.Join(parts, ";")
}

// manifestLanguage returns the language tag stamped on a manifest's
// synthetic file node. Used purely for Brief listings — the kind
// field carries the structural type.
Expand All @@ -7985,6 +8021,11 @@ func manifestLanguage(relPath string) string {
return "text"
case "pom.xml":
return "xml"
case "composer.json", "composer.lock":
// "json", never "php": the synthetic manifest node shares its ID with
// the JSON extractor's file node, and a php-tagged file would vouch
// for PHP presence in a repo that holds no PHP source.
return "json"
}
return ""
}
Expand Down
193 changes: 193 additions & 0 deletions internal/indexer/php_test_detection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package indexer

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"

"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
"github.com/zzet/gortex/internal/parser/languages"
)

func TestTestRole_PHPUnitMethodNaming(t *testing.T) {
assert.Equal(t, "test", TestRole("testHandlesEmptyRecord", "php"))
// The prefix must be followed by an uppercase letter, so ordinary methods
// that merely start with the letters are not swept in. A method named
// exactly `test` is left out for the same reason Go leaves `Test` out; in
// a test file it is classified by file membership anyway.
assert.Empty(t, TestRole("test", "php"))
assert.Empty(t, TestRole("testing", "php"))
assert.Empty(t, TestRole("testable", "php"))
assert.Empty(t, TestRole("setUp", "php"))
assert.Empty(t, TestRole("handle", "php"))
}

func TestAnnotationTestRole_PHPUnitAttributes(t *testing.T) {
assert.Equal(t, "test", AnnotationTestRole("php", "Test"))
assert.Equal(t, "test", AnnotationTestRole("php", `PHPUnit\Framework\Attributes\Test`))
assert.Equal(t, "test", AnnotationTestRole("php", "TestWith"))

// Metadata attributes describe a test rather than declaring one.
assert.Empty(t, AnnotationTestRole("php", "DataProvider"))
assert.Empty(t, AnnotationTestRole("php", "TestDox"))
assert.Empty(t, AnnotationTestRole("php", "CoversClass"))
}

func TestAnnotationTestRunner_PHP(t *testing.T) {
assert.Equal(t, "phpunit", AnnotationTestRunner("php"))
}

// newPHPTestIndexer registers only the PHP extractor.
func newPHPTestIndexer(g graph.Store) *Indexer {
reg := parser.NewRegistry()
reg.Register(languages.NewPHPExtractor())
cfg := config.Default().Index
cfg.Workers = 2
return New(g, reg, cfg, zap.NewNop())
}

func phpTestFixture(t *testing.T) string {
t.Helper()
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "tests"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "src"), 0o755))

writeFile(t, filepath.Join(dir, "src", "Calculator.php"), `<?php
namespace App;

class Calculator {
public function add(int $a, int $b): int { return $a + $b; }
}
`)
writeFile(t, filepath.Join(dir, "tests", "CalculatorTest.php"), `<?php
namespace App\Tests;

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;

class CalculatorTest extends TestCase
{
public function testAddsTwoNumbers(): void {}

#[Test]
public function itAddsNegatives(): void {}

/**
* @test
*/
public function addsZero(): void {}

public function helperNotATest(): void {}
}
`)
return dir
}

func TestPHPTestDetection_EndToEnd(t *testing.T) {
dir := phpTestFixture(t)
g := graph.New()
idx := newPHPTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)

byName := map[string]*graph.Node{}
var testFile *graph.Node
for _, n := range g.AllNodes() {
if n.Kind == graph.KindMethod {
byName[n.Name] = n
}
if n.Kind == graph.KindFile && filepath.Base(n.FilePath) == "CalculatorTest.php" {
testFile = n
}
}

require.NotNil(t, testFile, "no file node for CalculatorTest.php")
assert.Equal(t, true, testFile.Meta["is_test_file"], "*Test.php is a test file")
assert.Equal(t, "phpunit", testFile.Meta["test_runner"],
"a file importing PHPUnit\\Framework runs under phpunit")

for _, name := range []string{"testAddsTwoNumbers", "itAddsNegatives", "addsZero"} {
n := byName[name]
require.NotNil(t, n, "no method node for %s", name)
assert.Equal(t, "test", n.Meta["test_role"], "%s must classify as a test: %v", name, n.Meta)
}

// A helper inside a test class is still swept in by file membership —
// that is the existing cross-language contract, not a PHP quirk — but the
// production class's method must not be.
add := byName["add"]
require.NotNil(t, add)
assert.NotContains(t, add.Meta, "test_role", "a production method is not a test")
}

// A `#[Test]` method in a file whose name carries no Test suffix is still a
// test — the attribute is the signal, and it selects phpunit as the runner.
func TestPHPTestDetection_AttributeOutsideTestFile(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "Suite.php"), `<?php
namespace App;

use PHPUnit\Framework\Attributes\Test;

class Suite
{
#[Test]
public function checksInvariant(): void {}

public function ordinary(): void {}
}
`)
g := graph.New()
idx := newPHPTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)

var marked, plain *graph.Node
for _, n := range g.AllNodes() {
switch n.Name {
case "checksInvariant":
marked = n
case "ordinary":
plain = n
}
}
require.NotNil(t, marked)
assert.Equal(t, "test", marked.Meta["test_role"])
assert.Equal(t, "phpunit", marked.Meta["test_runner"])

require.NotNil(t, plain)
assert.NotContains(t, plain.Meta, "test_role")
}

func TestPHPTestDetection_PestRunner(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "tests"), 0o755))
writeFile(t, filepath.Join(dir, "tests", "ExampleTest.php"), `<?php
namespace Tests;

use Pest\TestSuite;

class ExampleTest {
public function testSomething(): void {}
}
`)
g := graph.New()
idx := newPHPTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)

for _, n := range g.AllNodes() {
if n.Kind == graph.KindFile && filepath.Base(n.FilePath) == "ExampleTest.php" {
assert.Equal(t, "pest", n.Meta["test_runner"],
"an imported Pest namespace picks pest over the phpunit default")
return
}
}
t.Fatal("no file node for ExampleTest.php")
}
27 changes: 27 additions & 0 deletions internal/indexer/test_edges.go
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,11 @@ func detectTestRunnerForFileEdges(fileNode *graph.Node, outEdges []*graph.Edge)
case strings.HasSuffix(stem, "_test"):
return "minitest"
}
case "php":
// PHPUnit is the default when nothing more specific was imported.
// Pest reuses the same file layout, so it is only claimed when the
// import signal above actually saw it.
return "phpunit"
}
return ""
}
Expand Down Expand Up @@ -644,6 +649,28 @@ func detectRunnerFromImportEdgeSlice(fileNode *graph.Node, edges []*graph.Edge)
path == "@types/mocha", path == "mochawesome":
return "mocha"
}
case "php":
// Read the namespace off the edge rather than its target: a PHP
// import that resolved to the class it names — or fell through to
// an external:: stub — no longer carries the specifier in e.To.
// Meta["fqn"] is stamped at extraction and survives both.
ns := path
if e.Meta != nil {
if fqn, _ := e.Meta["fqn"].(string); fqn != "" {
ns = fqn
}
}
ns = strings.ReplaceAll(ns, `\`, "/")
switch {
case ns == "Pest" || strings.HasPrefix(ns, "Pest/"):
return "pest"
case strings.HasPrefix(ns, "PHPUnit/"):
return "phpunit"
case strings.HasPrefix(ns, "Codeception/"):
return "codeception"
case strings.HasPrefix(ns, "Behat/"):
return "behat"
}
case "python":
switch {
case path == "pytest" || strings.HasPrefix(path, "pytest."),
Expand Down
23 changes: 23 additions & 0 deletions internal/indexer/testpattern.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ func TestRole(name, language string) string {
if strings.HasPrefix(name, "test_") {
return "test"
}
case "php":
// PHPUnit discovers `public function testFoo()`. The prefix must be
// followed by an uppercase letter or nothing, so `testing()` and
// `testable()` — ordinary methods — are not swept in.
if hasTestPrefix(name, "test") {
return "test"
}
}
return ""
}
Expand Down Expand Up @@ -140,6 +147,20 @@ func AnnotationTestRole(language, name string) string {
case name == "rstest" || name == "test_case" || name == "googletest":
return "test"
}
case "php":
// PHPUnit 10+ marks tests with attributes under
// PHPUnit\Framework\Attributes, written either bare (`#[Test]`) or
// fully qualified; the docblock form (`@test`) reaches here through
// the same path. `#[DataProvider]` and `#[TestDox]` describe a test
// rather than declaring one, so they carry no role.
short := name
if i := strings.LastIndex(short, `\`); i >= 0 {
short = short[i+1:]
}
switch short {
case "Test", "test", "TestWith", "TestWithJson":
return "test"
}
case "java", "kotlin":
short := name
if i := strings.LastIndexByte(short, '.'); i >= 0 {
Expand All @@ -163,6 +184,8 @@ func AnnotationTestRunner(language string) string {
return "cargo-test"
case "java", "kotlin":
return "junit"
case "php":
return "phpunit"
}
return ""
}
Expand Down
Loading
Loading