Skip to content

C# (and Java): record primary-constructor components are not exposed as auto-properties / accessors — definition X on record Point(int X, int Y) returns nothing #220

Description

@Widthdom

Summary

C# positional records (record Point(int X, int Y), readonly record struct Vec3(double X, double Y, double Z)) desugar to auto-properties with init-only setters. Semantically, X / Y / Z ARE the record's public API — they're what every caller reads and what record's equality / with-expressions are built on. But the C# symbol extractor indexes only the record shell and drops every positional component.

Same bug in Java. Java 16+ records record Point(int x, int y) expose auto-accessors x() / y() with the same semantic role. cdidx captures only the record shell; definition x on a Java record file returns nothing.

The user impact is the same in both languages: anyone asking an AI agent "find the definition of X" on a typical immutable-data codebase gets No symbols found. for every field that lives on a positional record.

Repro

C#

CDIDX=/root/.local/bin/cdidx
mkdir -p /tmp/dogfood/csrec
cat > /tmp/dogfood/csrec/R.cs <<'EOF'
namespace App;

public record Point(int X, int Y);
public readonly record struct Vec3(double X, double Y, double Z);
public record Animal(string Name);
public record Dog(string Name, string Breed) : Animal(Name);
public record Options(string Host, int Port) { public bool UseTls { get; init; } = true; }
public record Container<T>(T Value, int Count) where T : class;
EOF
"$CDIDX" /tmp/dogfood/csrec --db /tmp/dogfood/csrec.db
"$CDIDX" symbols --db /tmp/dogfood/csrec.db --limit 100

Actual output (abridged): only Point, Vec3, Animal, Dog, Options, Container show up, plus the UseTls body property. Not a single X, Y, Z, Name, Breed, Host, Port, Value, Count. definition Host --exactNo symbols found..

Java

mkdir -p /tmp/dogfood/javarec
cat > /tmp/dogfood/javarec/R.java <<'EOF'
package com.example;

public record Point(int x, int y) {}
public record Range(int low, int high) {
    public Range {
        if (low > high) throw new IllegalArgumentException();
    }
}
EOF
"$CDIDX" /tmp/dogfood/javarec --db /tmp/dogfood/javarec.db
"$CDIDX" symbols --db /tmp/dogfood/javarec.db

Actual: class Point, class Range rows — no x, y, low, high. The Java record's compact constructor public Range { ... } is also dropped (separate issue; see companion bug report).

Suspected root cause (from reading the source)

src/CodeIndex/Indexer/SymbolExtractor.cs:81 (C# class / record pattern):

new("class", new Regex(
    @"^\s*(?:(?<visibility>...)?\s+)?(?:(?:static|partial|abstract|sealed|readonly|file|new|unsafe)\s+)*"
  + @"(?:record\s+class\s+|record\s+|class\s+)(?<name>\w+)",
    ...), BodyStyle.Brace, "visibility"),

The pattern captures the record's name but does nothing with the parenthesized parameter list that follows. There is no post-processing step to parse (int X, int Y) and emit synthetic auto-property symbols for each positional component.

src/CodeIndex/Indexer/SymbolExtractor.cs:167 (Java record pattern):

new("class", new Regex(
    @"^\s*(?<visibility>public|private|protected)?\s*(?:(?:static|final|abstract|sealed|non-sealed|strictfp)\s+)*record\s+(?<name>\w+)",
    ...), BodyStyle.Brace, "visibility"),

Same structure — parameter list is not examined.

Suggested direction

Add a second pass specific to record lines (both languages):

  1. Match the primary-constructor parameter list on the same line (or across continuation lines if the declaration wraps).
  2. For each <ParameterType> <ParameterName>, emit a synthetic property symbol at the same line number, with the record's name stored as the enclosing symbol.

Minimal C# sketch (one additional recognizer, invoked only when the outer pattern matched a record):

// After a record shell match, scan "(...)" for parameters
// For each "Type Name[,)]", emit: new SymbolRecord("property", Name, recordLine, ..., enclosing: recordName)

Java: same approach; parameter naming convention is lowercase but the extraction is identical.

This is a one-pattern / one-post-processor change per language, and it lights up what users already think of as "the fields of a record."

Why it matters

  • Positional records are the idiomatic immutable-data form in modern C# (from C# 9 onward) and Java (from Java 16 onward). Large codebases routinely have hundreds of them.
  • Every primary-constructor component is both a field and an auto-property — it's the first thing AI agents search for when navigating a record. The current behavior returns No symbols found. for every such field.
  • hotspots, unused, and map all systematically underreport record components today.
  • Because the bug is silent, an AI client looking at its outline for a record sees an empty body and may conclude the record "has no members," when in reality it has 3 public auto-properties.

Scope

  • src/CodeIndex/Indexer/SymbolExtractor.cs — add record-parameter post-processing for both csharp and java language arrays.
  • tests/CodeIndex.Tests/SymbolExtractorTests.cs — fixtures for each language: positional record, positional record with additional body members, generic record, inheriting record.
  • DEVELOPER_GUIDE.md language-pattern reference table (both C# and Java rows).

Cross-language note

Same bug applies to Java — Java 16+ records expose x()-style accessors that are semantically identical to C# X { get; init; }. Any fix here should cover both languages in the same PR. (Kotlin data class primary parameters would be another natural extension, but kotlin extraction has different mechanics; keep that out of scope.)

Related

Environment

  • cdidx: v1.10.0 (tarball from GitHub releases).
  • Platform: linux-x64 container.
  • 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