Skip to content

PHP 8.1+: enum cases (case Pending;, case USD = 'USD';) are never captured as symbols — entire enum value surface is invisible to symbols / definition / references #318

Description

@Widthdom

Summary

PHP 8.1 introduced enum as a first-class language feature, including cases defined with the case keyword:

enum OrderStatus {
    case Pending;
    case Confirmed;
    case Shipped;
}

enum Priority: int {
    case Low = 1;
    case Medium = 5;
    case High = 10;
}

cdidx's PHP pattern set at src/CodeIndex/Indexer/SymbolExtractor.cs:230-246 captures the enum type (enum\s+(?<name>\w+) at line 240) but has no row for enum cases. Every case X; / case X = value; line inside an enum body is silently dropped. So:

  • cdidx symbols Pending --lang phpNo symbols found.
  • cdidx definition USD --lang phpNo definitions found.
  • cdidx references Low --lang phpNo references found. (cases are also missing from the reference graph as definitions, and pattern-matching match ($this) { Priority::Low => ... } lines are indistinguishable from free-identifier references)
  • cdidx outline on an enum file shows the type range but lists zero cases inside.

PHP 8.1 reached GA in November 2021, so this syntax has been stable ~4.5 years. Modern Laravel / Symfony / custom PHP 8+ codebases use enums as the canonical replacement for class constants, so missing the case surface makes the entire value set invisible.

Repro

CDIDX=/root/.local/bin/cdidx
mkdir -p /tmp/dogfood/php-enum && cat > /tmp/dogfood/php-enum/E.php <<'EOF'
<?php
namespace App\Status;

enum OrderStatus {
    case Pending;
    case Confirmed;
    case Shipped;
    case Delivered;
}

enum Priority: int {
    case Low = 1;
    case Medium = 5;
    case High = 10;

    public function label(): string {
        return match ($this) {
            Priority::Low => 'low',
            Priority::Medium => 'medium',
            Priority::High => 'high',
        };
    }
}

enum Currency: string implements HasSymbol {
    case USD = 'USD';
    case EUR = 'EUR';
    case JPY = 'JPY';
}
EOF
"$CDIDX" index /tmp/dogfood/php-enum --rebuild
"$CDIDX" symbols --db /tmp/dogfood/php-enum/.cdidx/codeindex.db

Observed:

function   label                                    E.php:16-22
namespace  App\Status                               E.php:2
enum       Currency                                 E.php:25-29
enum       OrderStatus                              E.php:4-9
enum       Priority                                 E.php:11-23
(5 symbols in 1 files)

Expected: 13 additional rows — one per case inside each enum (4 in OrderStatus, 3 in Priority, 3 in Currency). Each should carry the enclosing enum name as its container (so outline can group them) and, for backed enums, the literal backing value should feed into signature / return-type metadata.

Suspected root cause

src/CodeIndex/Indexer/SymbolExtractor.cs:230-246:

["php"] =
[
    // Const declaration / 定数宣言
    new("function", new Regex(@"^\s*(?:(?<visibility>public|private|protected)\s+)?const\s+(?<name>\w+)\s*=", RegexOptions.Compiled), BodyStyle.None, "visibility"),
    new("function", new Regex(@"^\s*(?:(?<visibility>public|private|protected)\s+)?(?:(?:static|abstract|final)\s+)*function\s+(?<name>\w+)\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"),
    // Class with expanded modifiers: abstract, final, readonly (PHP 8.2+)
    new("class",    new Regex(@"^\s*(?:(?:abstract|final|readonly)\s+)*class\s+(?<name>\w+)", RegexOptions.Compiled), BodyStyle.Brace),
    new("interface", new Regex(@"^\s*interface\s+(?<name>\w+)", RegexOptions.Compiled), BodyStyle.Brace),
    new("interface", new Regex(@"^\s*trait\s+(?<name>\w+)", RegexOptions.Compiled), BodyStyle.Brace),
    new("enum",     new Regex(@"^\s*enum\s+(?<name>\w+)", RegexOptions.Compiled), BodyStyle.Brace),
    // Namespace / 名前空間
    new("namespace", new Regex(@"^\s*namespace\s+(?<name>[\w\\]+)", RegexOptions.Compiled), BodyStyle.Brace),
    // use (import) / use(インポート)
    new("import",   new Regex(@"^\s*use\s+(?<name>[\w\\]+)", RegexOptions.Compiled), BodyStyle.None),
    new("import",   new Regex(@"^\s*(?:require|include)(?:_once)?\s*\(?\s*(?<name>.+?)\s*\)?;", RegexOptions.Compiled), BodyStyle.None),
],

No row starts with case\s+. The closest row is const (line 233), which requires an = terminator — cases in a pure (unbacked) enum like case Pending; have no = and would fail that pattern even if it fired for case. Backed-enum cases (case USD = 'USD';) structurally resemble const but are declared with the case keyword.

Suggested direction

Add a dedicated row between the enum row and the namespace row (ordering matters only in that the row must precede any generic catch-all). The row produces property kind because cases are enum values, closest to property in the existing taxonomy (same choice Kotlin makes for val/var at line 200):

// PHP 8.1+ enum case — pure or backed (case USD = 'USD';)
// PHP 8.1+ 列挙ケース — pure / backed 両対応。
new("property", new Regex(
    @"^\s*case\s+(?<name>\w+)(?:\s*=\s*(?<returnType>[^;]+?))?\s*;",
    RegexOptions.Compiled),
    BodyStyle.None,
    ReturnTypeGroup: "returnType"),

Notes on the suggested pattern:

  • The optional =\s*(?<returnType>[^;]+?) captures the backing literal for backed enums, surfaced as the return_type column (consistent with how the const row at line 233 could also benefit from a value capture — separate concern). Pure enums simply skip this group.
  • The ; terminator on the line is essential: it distinguishes the declaration case Pending; from a PHP 8 match expression arm like Priority::Low => 'low', which has no case keyword (PHP match uses =>, not case, unlike switch).
  • The switch statement in PHP also uses case arms (case 1:, case 'x':), but those live inside switch bodies, terminate with : or ;-after-break, and their "name" is a literal value. A row anchored on case\s+\w+\s*(?:=\s*[^;]+)?\s*; won't match bare case 1: (no \w+ match on a numeric literal leading) or case 'x': (quoted literal, no leading word character), and the trailing ; is wrong for switch arms (they end in : or a break; on a later line). So false positives from inside switch bodies should be negligible.
  • Backed-enum cases with expression-valued literals (case Foo = self::computed();) do technically contain parens; the [^;]+? return-type capture accepts them as long as the terminator remains ;.

Why it matters

  • Laravel / Symfony enum idioms. Backed enums replace class constants for type-safe domain values — payment status, role, locale, scope, HTTP verb. Every value is effectively invisible.
  • references Priority::High (or even just references High) cannot find the case-declaration site via the graph — only the reference lines in match arms, which collapses into the existing name-collision surface.
  • outline / inspect show the enum type but no members, defeating the single feature that makes enums more useful than classes of constants: predictable shape.
  • Silent. No warning; the enum declaration is captured, giving the illusion the enum is fully indexed when actually only the type shell is present.

Cross-language note

Enum-case coverage across the languages cdidx supports:

Language Enum type captured Enum cases captured
C# ✅ (captured via comma-separated list handling)
Java ⚠️ similar to PHP — enum Foo { A, B; } — worth a spot check as a follow-up
Kotlin ✅ (enum class) ⚠️ captured indirectly via the val/var row if they have explicit terminators, but plain FOO, entries may drop — worth a spot check
Swift ❌ filed separately
Rust ⚠️ variants capture as fields of an enum body — worth a spot check
PHP this ticket
Go n/a (no enum keyword) n/a

PHP and Swift have the cleanest, most-defined enum case syntax and yet neither is captured. Both are filed as separate narrow-scope tickets since the fix regex differs per language (PHP uses ;, Swift uses newline termination with an optional =).

Scope

  • src/CodeIndex/Indexer/SymbolExtractor.cs:230-246 — add the single enum-case row shown above.
  • tests/CodeIndex.Tests/SymbolExtractorTests.cs — fixtures for: (a) pure enum case Pending;, (b) backed enum case USD = 'USD'; with the backing literal captured in return_type, (c) backed enum with int value case Low = 1;, (d) enum with implements clause, (e) regression: switch body case 1: / case 'x': is NOT matched by the new row, (f) regression: const FOO = 1; still matches the existing const row.
  • DEVELOPER_GUIDE.md language-pattern reference table — PHP row should list enum case support.

Related

Environment

  • cdidx v1.10.0 at /root/.local/bin/cdidx (trimmed release build installed via install.sh).
  • Fixture /tmp/dogfood/php-enum/E.php as above.
  • cdidx languages shows php with yes / no in Symbols/Graph columns (graph extraction is not PHP-supported, so reference-side gaps are not in scope here — this ticket is purely about symbol-side case coverage).
  • Platform: linux-x64 container.
  • Filed from a Claude Code cloud session per CLOUD_BOOTSTRAP_PROMPT.md.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions