Skip to content

C# 8 readonly instance indexers on structs (public readonly int this[int i] => ...) are silently dropped — readonly not in indexer-regex modifier list #352

@Widthdom

Description

@Widthdom

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];:

  1. ^\s* eats leading whitespace.
  2. visibility matches public and the trailing space.
  3. (?:(?:static|virtual|override|abstract|sealed|new)\s+)* attempts to match readonly. readonly is not in the list → zero iterations.
  4. Cursor at readonly int this[int i] => _arr[i];.
  5. returnType (?:global::)?[\w?.<>\[\],:]+ greedy matches readonly.
  6. \s+ matches the space.
  7. (?<name>this) literal requires the string this — next non-space is intfails.
  8. Regex backtracks: shorter returnType candidates (readonl, readon, …) never leave a \s+this lookahead → none anchor.
  9. 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.

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