Skip to content

C#: interpolated strings $"... {Foo()} ..." hide embedded call sites from the reference index; verbatim strings @"..." confuse the extractor on line continuations #264

Description

@Widthdom

Summary

ReferenceExtractor.StringLiteralRegex treats every "..." span as an opaque literal and replaces it with "" before the reference regex runs. This is correct for plain string literals, but C# supports two variants that contain executable code inside the quotes:

  1. Interpolated strings $"Hello {Foo()}" — the {Foo()} is compiled as a real call; cdidx erases it along with the surrounding literal, so Foo is never registered as a reference.
  2. Verbatim strings @"multi\nline" and verbatim interpolated $@"..." — these strings can span multiple source lines. StringLiteralRegex is line-scoped (no RegexOptions.Singleline, and the constructor at ReferenceExtractor.cs:71-73 uses [^"\\\\] which doesn't match the verbatim "" escape either), so the continuation lines look like plain code. Any identifier-with-paren inside the verbatim string body gets incorrectly captured as a call reference.

Both are silent. Interpolated-call sites are lost; verbatim-string continuation lines inject phantom references.

Repro

CDIDX=/root/.local/bin/cdidx
mkdir -p /tmp/dogfood/cs-interp
cat > /tmp/dogfood/cs-interp/I.cs <<'EOF'
namespace Demo;
public class Helper
{
    public static string GetName() => "bob";
    public static int    GetAge()  => 42;
    public static string Format(string s) => s;
}
public class Caller
{
    public void Work()
    {
        var s1 = $"Hello {Helper.GetName()}";                    // interpolated — dropped
        var s2 = $"Age: {Helper.GetAge()} years";                // dropped
        var s3 = $"Nested {Helper.Format(Helper.GetName())}";    // dropped
        var s4 = $@"Multi
line {Helper.GetName()} text";                                   // phantom capture on line 2
        var s5 = Helper.GetName();                               // plain — captured
    }
}
EOF
"$CDIDX" index /tmp/dogfood/cs-interp --rebuild
"$CDIDX" references GetName --db /tmp/dogfood/cs-interp/.cdidx/codeindex.db --exact
"$CDIDX" references GetAge  --db /tmp/dogfood/cs-interp/.cdidx/codeindex.db --exact
"$CDIDX" references Format  --db /tmp/dogfood/cs-interp/.cdidx/codeindex.db --exact

Observed:

--- references GetName ---
call   GetName   I.cs:15:14   in Work          ← continuation of verbatim string — WRONG capture
  line {Helper.GetName()} text";
call   GetName   I.cs:17:25   in Work          ← plain call
(2 references in 1 files)
                                                ← real calls on lines 12, 13, 14 are missing
--- references GetAge ---
No references found.                            ← `GetAge` in `$"Age: {Helper.GetAge()} years"` dropped
--- references Format ---
No references found.                            ← `Format` in `$"Nested {Helper.Format(...)}"` dropped

Expected: at least one reference row for each of GetName (lines 12, 14, 15, 17 — four real call sites), GetAge (line 13), and Format (line 14). The line-15 phantom capture should not be present.

Suspected root cause (from reading the source)

src/CodeIndex/Indexer/ReferenceExtractor.cs:71-73:

private static readonly Regex StringLiteralRegex = new(
    "\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'|`(?:\\\\.|[^`\\\\])*`",
    RegexOptions.Compiled);

Issues:

  1. No interpolation-hole skip. The regex is designed for plain literals. C# interpolated strings $"text {expr} text" hold real expressions inside {...} at the source level; erasing the whole span loses those expressions. Languages with the same pattern: JavaScript/TypeScript template literals (`${expr}`) — the backtick branch of this same regex has the same blind spot.

  2. No verbatim-string awareness. C# @"..." uses "" (doubled quote) as the escape for ", not \". The current class [^"\\\\] accepts any char except " or \, and the escape alternative \\\\. only handles \\X. For @"He said ""Hi"" and left", the regex stops at the first internal " on the character before Hi, thinking the literal has ended, and then re-enters the next literal at the start of Hi". This doesn't affect all verbatim strings but is wrong in general.

  3. Line-scoped application. PrepareLine() is called per source line. A verbatim string that spans multiple source lines:

    var s = $@"start
    middle {Foo()} more
    end";

    — only the first line has an opening ". Lines 2 and 3 are processed as plain code. middle {Foo()} more is scanned and Foo( matches CallRegex, yielding a phantom call reference that is actually inside a string. Line 3 has a dangling closing " that may then confuse the next line's literal tracking (it doesn't cross lines, but the leading " on line 3 looks like the start of a new empty string, so end before it is left as code and produces nothing obviously wrong — but the overall situation is fragile).

  4. Language-global. The same regex is applied to every language. Python f-strings f"text {expr}", JavaScript / TypeScript template literals `text ${expr}`, Kotlin "text $variable ${expr()}", and Swift "text \(expr)" all have the same "real code inside quotes" issue.

Suggested direction

Two complementary fixes:

1. Interpolation-hole preservation.

Add a language-aware pre-pass that, before the generic literal erasure, scans for interpolated-string spans and rewrites them to keep the hole contents while erasing the literal framing. For C#:

// Matches $"..." (non-verbatim) while preserving `{...}` hole contents.
private static readonly Regex CsInterpolatedStringRegex = new(
    @"\$""(?<body>(?:[^""{}\\]|\\.|\{\{|\}\}|\{[^{}]*\})*)""",
    RegexOptions.Compiled);

preparedLine = CsInterpolatedStringRegex.Replace(preparedLine, m =>
{
    // Replace literal text with spaces (preserve column offsets), keep hole contents.
    var body = m.Groups["body"].Value;
    // Scan body, replace non-hole chars with spaces; keep '{...}' contents intact.
    ...
});

For JS/TS template literals, mirror with `...${expr}...`. For Python f-strings, the same idea applies to f"..." and f'...'.

2. Verbatim / multi-line string masking.

Add a stateful pre-pass (or a single whole-file pass before line splitting) that tracks "inside a verbatim string" state across lines. When a verbatim opens on line N and closes on line M > N, mark lines N+1..M as "inside string" and replace the entire content of those lines with spaces before running the reference regexes. C# verbatim literals: @"...", $@"...", @$"...", and the C# 11 raw-string literals """...""" (which may also span multiple lines, with their own escaping rules).

A whole-file scan once per file is O(N) and avoids the per-line regex pathology. It also lets the interpolation-hole preservation work uniformly for single-line and multi-line interpolated strings.

Why it matters

  • Interpolated strings are ubiquitous in modern C#. Logging (_logger.LogInformation($"Started {service.Name}")), error messages (throw new Exception($"Missing {config.Key}")), SQL building ($"SELECT * FROM {table.Name}"), format strings everywhere. All of these service.Name, config.Key, table.Name calls are invisible to the reference index. For many service classes, Name/Id property accesses via interpolation are the majority of real usage.
  • Phantom captures from verbatim strings produce false positives in references, callers, and impact. A multi-line SQL @"SELECT foo FROM bar WHERE baz()" captured across lines yields a phantom baz() call. These false positives are harder to spot than simple missing data — the tool confidently points at an in-string location as a call site.
  • unused analysis. Properties/methods referenced only via interpolation show up as "zero callers" → false-positive unused.
  • Cross-language surface. JS/TS template literals, Python f-strings, Kotlin string interpolation, Swift \(expr) all exhibit the same "dropped call in interpolated string" pattern once fixed for C#.

Cross-language note

  • JavaScript/TypeScript `Hello ${getName()}` — the existing backtick branch of StringLiteralRegex already erases the whole thing, so getName is lost. Template literals are extremely common in modern JS/TS.
  • Python f-strings f"hello {get_name()}" — the "..." branch of the regex matches, erasing the interpolation. Standard Python 3.6+ syntax, ubiquitous.
  • Kotlin "Hello ${getName()} age=${age}" — same pattern as C# interpolation.
  • Scala s"Hello ${getName()}" — same pattern.
  • Swift "Hello \(getName())" — Swift uses \(expr) as the interpolation form.
  • Multi-line — C# @"...", Kotlin """...""", Python triple-quoted strings """...""", JS template literals `...\n...`, Scala """...""" — all can span lines; all suffer from the line-scoped erasure gap.

The two fixes above (interpolation-hole preservation, multi-line literal masking) together address the majority of this cross-language surface.

Scope

  • src/CodeIndex/Indexer/ReferenceExtractor.cs — language-aware interpolation-hole preservation; multi-line verbatim / triple-quoted / backtick-template masking.
  • tests/CodeIndex.Tests/ReferenceExtractorTests.cs — fixtures for C# $"...", $@"...", """...""" (C# 11 raw), JS/TS template literals, Python f-strings, Kotlin interpolation, multi-line verbatim.
  • CLAUDE.md design-decisions — note the stateful pre-pass (one-per-file) to keep per-line reference scanning correct.

Related

Environment

  • cdidx v1.10.0 (installed via install.sh to /root/.local/bin/cdidx).
  • 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