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 php → No symbols found.
cdidx definition USD --lang php → No definitions found.
cdidx references Low --lang php → No 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.
Summary
PHP 8.1 introduced
enumas a first-class language feature, including cases defined with thecasekeyword:cdidx's PHP pattern set at
src/CodeIndex/Indexer/SymbolExtractor.cs:230-246captures the enum type (enum\s+(?<name>\w+)at line 240) but has no row for enum cases. Everycase X;/case X = value;line inside an enum body is silently dropped. So:cdidx symbols Pending --lang php→No symbols found.cdidx definition USD --lang php→No definitions found.cdidx references Low --lang php→No references found.(cases are also missing from the reference graph as definitions, and pattern-matchingmatch ($this) { Priority::Low => ... }lines are indistinguishable from free-identifier references)cdidx outlineon 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
Observed:
Expected: 13 additional rows — one per
caseinside each enum (4 inOrderStatus, 3 inPriority, 3 inCurrency). Each should carry the enclosing enum name as its container (sooutlinecan 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:No row starts with
case\s+. The closest row isconst(line 233), which requires an=terminator — cases in a pure (unbacked) enum likecase Pending;have no=and would fail that pattern even if it fired forcase. Backed-enum cases (case USD = 'USD';) structurally resembleconstbut are declared with thecasekeyword.Suggested direction
Add a dedicated row between the
enumrow and thenamespacerow (ordering matters only in that the row must precede any generic catch-all). The row producespropertykind because cases are enum values, closest to property in the existing taxonomy (same choice Kotlin makes forval/varat line 200):Notes on the suggested pattern:
=\s*(?<returnType>[^;]+?)captures the backing literal for backed enums, surfaced as thereturn_typecolumn (consistent with how theconstrow at line 233 could also benefit from a value capture — separate concern). Pure enums simply skip this group.;terminator on the line is essential: it distinguishes the declarationcase Pending;from a PHP 8matchexpression arm likePriority::Low => 'low',which has nocasekeyword (PHPmatchuses=>, notcase, unlikeswitch).switchstatement in PHP also usescasearms (case 1:,case 'x':), but those live insideswitchbodies, terminate with:or;-after-break, and their "name" is a literal value. A row anchored oncase\s+\w+\s*(?:=\s*[^;]+)?\s*;won't match barecase 1:(no\w+match on a numeric literal leading) orcase 'x':(quoted literal, no leading word character), and the trailing;is wrong for switch arms (they end in:or abreak;on a later line). So false positives from insideswitchbodies should be negligible.case Foo = self::computed();) do technically contain parens; the[^;]+?return-type capture accepts them as long as the terminator remains;.Why it matters
references Priority::High(or even justreferences 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/inspectshow the enum type but no members, defeating the single feature that makes enums more useful than classes of constants: predictable shape.Cross-language note
Enum-case coverage across the languages cdidx supports:
enum Foo { A, B; }— worth a spot check as a follow-upenum class)val/varrow if they have explicit terminators, but plainFOO,entries may drop — worth a spot checkPHP 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 enumcase Pending;, (b) backed enumcase USD = 'USD';with the backing literal captured inreturn_type, (c) backed enum with int valuecase Low = 1;, (d) enum withimplementsclause, (e) regression:switchbodycase 1:/case 'x':is NOT matched by the new row, (f) regression:const FOO = 1;still matches the existing const row.DEVELOPER_GUIDE.mdlanguage-pattern reference table — PHP row should list enum case support.Related
public int Count;,private readonly List<int> _items;,public static int GlobalCount;) are not captured as symbols — onlyconstandstatic readonlyare indexed #298 — C# plain fields dropped. Same "class/enum member coverage gap" family.classacross Rust / TypeScript / Swift / Go / F# (and silently dropped in Scala) #206 — Type-alias kind misclassification. Same file, unrelated construct.enummis-kinded asclass,package/oneof/extendnever captured (5,815 enum + 6,932 package + 2,572 oneof misses on googleapis) #172 — Protobufenummiskinded asclass. Adjacent kind-mapping gap.Environment
/root/.local/bin/cdidx(trimmed release build installed viainstall.sh)./tmp/dogfood/php-enum/E.phpas above.cdidx languagesshowsphpwithyes/noin 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).CLOUD_BOOTSTRAP_PROMPT.md.