Summary
C# 8 extended the readonly modifier beyond fields to instance members on structs — methods, properties, and indexers — so that a struct can advertise which instance members do not mutate this. SymbolExtractor added readonly to the method regex modifier list at :94 (so public readonly int ReadMe() => 0; on a struct is captured correctly), but not to the indexer regex at :118 and not to the property regex (the property case is tracked separately in #327). As a result, every readonly indexer declaration on a struct is silently dropped from the symbol index, even though readonly indexers are a real, shipped C# 8 feature and are idiomatic for immutable-view struct wrappers (Span<T>, ReadOnlySpan<T>, custom slice types, immutable matrix/vector types).
public struct S
{
private int[] _arr;
// DROPPED — `readonly` not in :118 modifier list
public readonly int this[int i] => _arr[i];
// DROPPED — same row, block accessor form
public readonly string this[string key]
{
get => key;
}
// Captured — method regex has `readonly` in its modifier list
public readonly int ReadMe() => 0;
// Captured — non-readonly baseline
public int this[long key] => 0;
}
definition this --exact misses every readonly indexer, symbols --kind function filtered to the struct undercounts its public surface, and inspect on the enclosing struct reports the baseline indexer but not the readonly ones. outline S.cs omits the same rows.
This is distinct from:
Repro
CDIDX=/root/.local/bin/cdidx
mkdir -p /tmp/dogfood/cs-readonly-indexer
cat > /tmp/dogfood/cs-readonly-indexer/R.cs <<'EOF'
namespace CsReadonlyIndexer;
public struct S
{
private int[] _arr;
// readonly method — CAPTURED (method regex :94 includes readonly)
public readonly int ReadMe() => 0;
// readonly indexer, expression body — DROPPED
public readonly int this[int i] => _arr[i];
// readonly indexer, block body — DROPPED
public readonly string this[string key]
{
get => key;
}
// readonly property — DROPPED (tracked by #327)
public readonly int P1 => 0;
// Baselines — CAPTURED
public int this[long key] => 0;
public int P2 => 1;
public int M() => 2;
}
EOF
"$CDIDX" index /tmp/dogfood/cs-readonly-indexer --rebuild
"$CDIDX" symbols --db /tmp/dogfood/cs-readonly-indexer/.cdidx/codeindex.db
Observed:
function M R.cs:27
property P2 R.cs:24
function ReadMe R.cs:8
struct S R.cs:3-28
function this R.cs:21 ← baseline indexer only
namespace CsReadonlyIndexer R.cs:1
(6 symbols in 1 files)
Missing: the two readonly indexer declarations at R.cs:11 and R.cs:14-17. definition this --exact returns only the baseline this[long] row at R.cs:21, making callers, references, and inspect materially wrong on any struct whose access surface is exposed through readonly indexers — which is ubiquitous in ReadOnlySpan-style wrapper types.
Suspected root cause (from reading the source)
src/CodeIndex/Indexer/SymbolExtractor.cs:118 — the C# indexer row:
new("function", new Regex(
@"^\s*(?:(?<visibility>public|private|protected\s+internal|private\s+protected|protected|internal)\s+)?"
+ @"(?:(?:static|virtual|override|abstract|sealed|new)\s+)*"
+ @"(?<returnType>(?:global::)?[\w?.<>\[\],:]+)\s+(?<name>this)\s*\[",
RegexOptions.Compiled),
BodyStyle.Brace, "visibility", "returnType"),
The modifier alternation is static|virtual|override|abstract|sealed|new. Missing: readonly (and also partial — tracked by #350, and arguably required / extern for completeness).
Walkthrough for public readonly int this[int i] => _arr[i];:
^\s* eats leading whitespace.
visibility matches public and the trailing space.
(?:(?:static|virtual|override|abstract|sealed|new)\s+)* attempts to match readonly. readonly is not in the list → zero iterations.
- Cursor at
readonly int this[int i] => _arr[i];.
returnType (?:global::)?[\w?.<>\[\],:]+ greedy matches readonly.
\s+ matches the space.
(?<name>this) literal requires the string this — next non-space is int → fails.
- Regex backtracks: shorter returnType candidates (
readonl, readon, …) never leave a \s+this lookahead → none anchor.
- Whole regex fails.
No other C# pattern rescues the line:
- Method regex
:94 requires \s*(?:<[^>]+>\s*)?\( after the name; the indexer line has [ → fail.
- Explicit interface regex
:116 requires a dotted qualifier before the name (IFoo.this) — not present on a regular this[...] indexer → fail.
- Property regexes
:100 / :103 require a name that is \w+ only — this matches \w+, but the next token after the name must be { (:100) or => (:103), and the indexer line has [ → fail on both.
- Ctor regex
:97 requires a trailing ( — we have [ → fail.
Net: silent drop. No warning, no phantom. (Contrast with #336 / #340 / #349 where visibility-backtrack fires a fallback phantom — for the indexer line there is no fallback row that fires because every candidate's tail anchor is ( or { or =>, not [.)
Suggested direction
(A) Add readonly to the indexer regex modifier alternation at :118:
// :118 — change
(?:(?:static|virtual|override|abstract|sealed|new)\s+)*
// to
(?:(?:static|virtual|override|abstract|sealed|new|readonly|partial|required|extern)\s+)*
readonly is the primary fix (C# 8). partial (C# 13, tracked by #350) is sensible to fold in alongside — same regex, same single-word edit. required is arguably not valid on indexers per the C# spec but matches the property row's modifier set and is harmless. extern covers the P/Invoke-on-struct case.
(B) Mirror on the property regexes at :100 / :103 — #327's scope. A sweep-style patch that lands readonly on all three rows together (property-brace, property-expr, indexer) keeps the modifier surface aligned. #327's suggested fix should be cross-referenced by whoever lands either ticket.
(C) Tests. Add fixtures to SymbolExtractorTests.cs covering:
public readonly int this[int i] => _arr[i]; → captured as indexer row, name this.
public readonly string this[string key] { get => key; } → captured.
public readonly T this[int i] { get; } (generic readonly indexer) → captured.
- Regression:
public int this[long key] => 0; (non-readonly baseline) → still captured.
- Regression:
public readonly int ReadMe() => 0; (readonly method — unchanged regex row) → still captured.
Why it matters
Cross-language note
The readonly modifier for struct instance members is a C#-specific feature. Other languages either don't have value-type semantics (Java, Python), handle immutability at the type level (Rust's &self vs &mut self), or use separate keywords (Swift's mutating / non-mutating). The fix is C#-scoped.
Scope
Related
Environment
- cdidx: v1.10.0 (
/root/.local/bin/cdidx).
- Platform: linux-x64.
- Fixture:
/tmp/dogfood/cs-readonly-indexer/R.cs (inline in Repro).
- Filed from a cloud Claude Code session per
CLOUD_BOOTSTRAP_PROMPT.md.
Summary
C# 8 extended the
readonlymodifier beyond fields to instance members on structs — methods, properties, and indexers — so that a struct can advertise which instance members do not mutatethis.SymbolExtractoraddedreadonlyto the method regex modifier list at:94(sopublic readonly int ReadMe() => 0;on a struct is captured correctly), but not to the indexer regex at:118and not to the property regex (the property case is tracked separately in #327). As a result, everyreadonlyindexer declaration on a struct is silently dropped from the symbol index, even thoughreadonlyindexers are a real, shipped C# 8 feature and are idiomatic for immutable-view struct wrappers (Span<T>,ReadOnlySpan<T>, custom slice types, immutable matrix/vector types).definition this --exactmisses everyreadonlyindexer,symbols --kind functionfiltered to the struct undercounts its public surface, andinspecton the enclosing struct reports the baseline indexer but not thereadonlyones.outline S.csomits the same rows.This is distinct from:
readonlyproperties silently dropped ANDreadonly get =>accessor bodies misclassified as phantomproperty getrows —readonlymissing from property regex modifier slot #327 —readonlyproperty drops. Scope isSymbolExtractor.cs:100/:103(the two property regexes). The indexer lives in its own regex at:118with its own modifier list, so C#:readonlyproperties silently dropped ANDreadonly get =>accessor bodies misclassified as phantomproperty getrows —readonlymissing from property regex modifier slot #327's fix does not cover the indexer row. Filing separately so the fix lands in the right regex.public partial int this[int i] { get; set; }) are silently dropped —partialnot in indexer-regex modifier list #350 — partial indexers. Same regex:118, same modifier-list-gap family, different keyword (partialvsreadonly). Adjacent and could be fixed together in the same commit.ref/ref readonlyreturn types on methods and properties are silently dropped —public ref T Find(...)produces zero symbols #224 —ref/ref readonlyreturn types on methods and properties. Same keywordreadonlyin the source text but different grammatical role (return-type slot, not modifier slot), different regex rows, different fix shape.public readonly (int, int) X;) emit phantomfunction readonlyrows — method regex backtrackspublicinto returnType #336 — phantomfunction readonlyfrom readonly fields with tuple return. Different mechanism (visibility-backtrack), different regex, the indexer case does not produce a phantom — just a silent drop.Repro
Observed:
Missing: the two
readonlyindexer declarations at R.cs:11 and R.cs:14-17.definition this --exactreturns only the baselinethis[long]row at R.cs:21, makingcallers,references, andinspectmaterially wrong on any struct whose access surface is exposed throughreadonlyindexers — which is ubiquitous inReadOnlySpan-style wrapper types.Suspected root cause (from reading the source)
src/CodeIndex/Indexer/SymbolExtractor.cs:118— the C# indexer row:The modifier alternation is
static|virtual|override|abstract|sealed|new. Missing:readonly(and alsopartial— tracked by #350, and arguablyrequired/externfor completeness).Walkthrough for
public readonly int this[int i] => _arr[i];:^\s*eats leading whitespace.visibilitymatchespublicand the trailing space.(?:(?:static|virtual|override|abstract|sealed|new)\s+)*attempts to matchreadonly.readonlyis not in the list → zero iterations.readonly int this[int i] => _arr[i];.returnType (?:global::)?[\w?.<>\[\],:]+greedy matchesreadonly.\s+matches the space.(?<name>this)literal requires the stringthis— next non-space isint→ fails.readonl,readon, …) never leave a\s+thislookahead → none anchor.No other C# pattern rescues the line:
:94requires\s*(?:<[^>]+>\s*)?\(after the name; the indexer line has[→ fail.:116requires a dotted qualifier before the name (IFoo.this) — not present on a regularthis[...]indexer → fail.:100/:103require a name that is\w+only —thismatches\w+, but the next token after the name must be{(:100) or=>(:103), and the indexer line has[→ fail on both.:97requires a trailing(— we have[→ fail.Net: silent drop. No warning, no phantom. (Contrast with #336 / #340 / #349 where visibility-backtrack fires a fallback phantom — for the indexer line there is no fallback row that fires because every candidate's tail anchor is
(or{or=>, not[.)Suggested direction
(A) Add
readonlyto the indexer regex modifier alternation at:118:readonlyis the primary fix (C# 8).partial(C# 13, tracked by #350) is sensible to fold in alongside — same regex, same single-word edit.requiredis arguably not valid on indexers per the C# spec but matches the property row's modifier set and is harmless.externcovers the P/Invoke-on-struct case.(B) Mirror on the property regexes at
:100/:103— #327's scope. A sweep-style patch that landsreadonlyon all three rows together (property-brace, property-expr, indexer) keeps the modifier surface aligned. #327's suggested fix should be cross-referenced by whoever lands either ticket.(C) Tests. Add fixtures to
SymbolExtractorTests.cscovering:public readonly int this[int i] => _arr[i];→ captured as indexer row, namethis.public readonly string this[string key] { get => key; }→ captured.public readonly T this[int i] { get; }(generic readonly indexer) → captured.public int this[long key] => 0;(non-readonly baseline) → still captured.public readonly int ReadMe() => 0;(readonly method — unchanged regex row) → still captured.Why it matters
readonlyindexers are the canonical shape forSpan<T>/ReadOnlySpan<T>/ custom slice / matrix-row / vector-component views that don't mutatethis. Any codebase usingreadonly structwith indexed access will have these members and they all silently vanish from the index.definition this,outline,inspect,callers,hotspots --kind functionall under-report on struct files withreadonlyindexers. No warning surfaces to the user.readonlyindexer, because the symbol-side definition is absent.System.ReadOnlySpan<T>itself and similar BCLreadonly structtypes expose their indexers withreadonly. Any user code that mimics this pattern is affected.public partial string Name { get; set; }) silently dropped —partialnot in property-regex modifier list #228 / C#:readonlyproperties silently dropped ANDreadonly get =>accessor bodies misclassified as phantomproperty getrows —readonlymissing from property regex modifier slot #327 / C# 13 partial indexers (public partial int this[int i] { get; set; }) are silently dropped —partialnot in indexer-regex modifier list #350 for properties and the partial modifier.Cross-language note
The
readonlymodifier for struct instance members is a C#-specific feature. Other languages either don't have value-type semantics (Java, Python), handle immutability at the type level (Rust's&selfvs&mut self), or use separate keywords (Swift'smutating/ non-mutating). The fix is C#-scoped.Scope
src/CodeIndex/Indexer/SymbolExtractor.cs:118— indexer regex modifier list — addreadonly(and optionallypartial/required/extern).src/CodeIndex/Indexer/SymbolExtractor.cs:100/:103(property regexes) — C#:readonlyproperties silently dropped ANDreadonly get =>accessor bodies misclassified as phantomproperty getrows —readonlymissing from property regex modifier slot #327's scope — soreadonlylands on all three rows in one commit.tests/CodeIndex.Tests/SymbolExtractorTests.cs— fixtures listed in the Suggested direction (C) section, plus regression for the non-readonly indexer baseline.DEVELOPER_GUIDE.mdlanguage-pattern reference table — C# row's indexer entry should listreadonlyas a supported modifier.Related
readonlyproperties silently dropped ANDreadonly get =>accessor bodies misclassified as phantomproperty getrows —readonlymissing from property regex modifier slot #327 —readonlyproperties silently dropped. Sibling fix on the property regexes; same C# 8 feature family.public partial int this[int i] { get; set; }) are silently dropped —partialnot in indexer-regex modifier list #350 — C# 13 partial indexers silently dropped. Same indexer regex:118, different missing keyword (partial). A single patch can cover both.public partial string Name { get; set; }) silently dropped —partialnot in property-regex modifier list #228 — C# 13 partial properties silently dropped. Same "modifier slot narrower than real-world grammar" family.ref/ref readonlyreturn types on methods and properties are silently dropped —public ref T Find(...)produces zero symbols #224 —ref/ref readonlyreturn types on methods and properties silently dropped. Same keywordreadonlyin the source text but different regex role (return-type slot, not modifier slot).abstract/virtual/override/sealed/newmodifiers are silently dropped from the symbol index #334 — C# events withabstract/virtual/override/sealed/newmodifiers silently dropped. Same family (modifier slot narrower than grammar) on the event regex.public readonly (int, int) X;) emit phantomfunction readonlyrows — method regex backtrackspublicinto returnType #336 — readonly fields with tuple types emit phantomfunction readonly. Different mechanism (visibility-backtrack), same keyword.partial,required,readonly) + tuple-suffix return type emit phantomfunction partial/function required/function readonlyrows via ctor-regex fallback #349 — ctor-regex fallback phantoms forreadonly+ tuple-suffix return type. Phantom family, related but distinct from this silent-drop case.Environment
/root/.local/bin/cdidx)./tmp/dogfood/cs-readonly-indexer/R.cs(inline in Repro).CLOUD_BOOTSTRAP_PROMPT.md.