Skip to content

C# StripLeadingCSharpAttributeLists returns original line when attribute consumes entire line — comma-separated multi-attribute lists produce phantom function via :116 #368

Description

@Widthdom

Summary

StripLeadingCSharpAttributeLists at SymbolExtractor.cs:623-661 has an off-by-one fallback: when the attribute list consumes the entire line (ending with ] at the last column, followed by newline), cursor == line.Length is reached, and the function returns the original line instead of an empty string. This allows regex patterns to match attribute content as if it were code.

The bug is masked for single-attribute lines because the stripped-style attribute content doesn't match any method regex anchor. But it manifests for comma-separated multi-attribute lines ([Attr1, Namespace.Attr2("arg")]) where the comma + following space create an alignment that satisfies the explicit interface regex at :116, producing a phantom function symbol.

Repro

CDIDX=/root/.local/bin/cdidx
mkdir -p /tmp/dogfood/cs-attr-strip-bug
cat > /tmp/dogfood/cs-attr-strip-bug/A.cs <<'EOF'
namespace AttrStripBug;

public class Svc
{
    // Single attribute — works correctly (no phantom)
    [System.ComponentModel.DisplayName("Hidden")]
    public void Single() { }

    // Comma-separated attributes — PHANTOM `function DisplayName` emitted
    [System.Obsolete, System.ComponentModel.DisplayName("Hidden")]
    public void Combo() { }
}
EOF
"$CDIDX" index /tmp/dogfood/cs-attr-strip-bug --rebuild
"$CDIDX" symbols --db /tmp/dogfood/cs-attr-strip-bug/.cdidx/codeindex.db

Expected: Single, Combo, Svc, namespace = 4 symbols.

Actual:

function   Combo                   A.cs:11
function   DisplayName             A.cs:10-11    ← PHANTOM (body includes next line)
function   Single                  A.cs:7
class      Svc                     A.cs:3-12
namespace  AttrStripBug            A.cs:1

DisplayName at A.cs:10-11 is a phantom created from the content of the attribute list on line 10.

Suspected root cause

src/CodeIndex/Indexer/SymbolExtractor.cs:623-661StripLeadingCSharpAttributeLists:

private static string StripLeadingCSharpAttributeLists(string line)
{
    var index = 0;
    while (index < line.Length && char.IsWhiteSpace(line[index]))
        index++;

    if (index >= line.Length || line[index] != '[')
        return line;

    var cursor = index;
    while (cursor < line.Length && line[cursor] == '[')
    {
        var depth = 0;
        var sawBracket = false;
        while (cursor < line.Length)
        {
            var ch = line[cursor++];
            if (ch == '[')
            {
                depth++;
                sawBracket = true;
            }
            else if (ch == ']')
            {
                depth--;
                if (depth == 0 && sawBracket)
                    break;
            }
        }

        if (depth != 0)
            return line;

        while (cursor < line.Length && char.IsWhiteSpace(line[cursor]))
            cursor++;
    }

    return cursor < line.Length ? line[cursor..] : line;    // ← BUG
}

Trace for line 10 [System.Obsolete, System.ComponentModel.DisplayName("Hidden")] (length 66):

  1. index = 4 (after skipping 4 spaces).
  2. line[4] == '[', enter outer loop.
  3. Inner loop consumes through ] at position 65. cursor becomes 66 (past the ]).
  4. depth == 0, OK.
  5. Skip whitespace: cursor == 66 == line.Length, no more chars.
  6. Outer loop check: cursor < line.Length66 < 66 is false. Exit.
  7. Final return: cursor < line.Length ? line[cursor..] : line66 < 66 is false → returns line (the ORIGINAL).

Now the explicit interface regex at :116 processes the original line:

  • returnType [\w?.<>\[\],:]+ greedy matches [System.Obsolete, (positions 4-20, 17 chars — the char class includes [, ,).
  • \s+ matches the space at position 21.
  • Qualifier [\w.<>:]+ backtracks to System.ComponentModel (positions 22-42).
  • \. matches . at position 43.
  • (?<name>\w+) captures DisplayName (positions 44-54).
  • [\(\[] matches ( at position 55.

Phantom emitted: function DisplayName with body range starting at line 10 (via BodyStyle.Brace which finds { on the next line).

The single-attribute case [System.ComponentModel.DisplayName("Hidden")] ALSO hits the same stripping bug (returns the original line), but the explicit interface regex doesn't match because there's no , to create the intermediate whitespace alignment.

Suggested fix

The final return statement should use line[cursor..] unconditionally:

return line[cursor..];    // When cursor == line.Length, this returns ""

C#'s range operator handles cursor == line.Length correctly — it returns an empty string. The existing ternary is defensive programming gone wrong: it assumes line[cursor..] might throw, but it doesn't.

Alternative: keep the ternary but flip the fallback:

return cursor < line.Length ? line[cursor..] : string.Empty;

Both fixes produce the same behavior: when the attribute consumes the entire line, the stripped result is empty.

Defense in depth: the explicit interface regex at :116 could also add [ to its negative returnType lookahead. [ is not a legal starting character for a C# return type, so excluding it would harden against any future similar stripping failure. But fixing the stripper is sufficient.

Why it matters

  • Attribute-list phantom symbols pollute symbols, definition, outline, inspect, map. Any codebase using comma-separated attributes ([Obsolete, DisplayName("x")] is idiomatic short-form) gets phantom rows.
  • Real-world frequency: [Flags, Serializable], [JsonIgnore, XmlIgnore], [Obsolete, EditorBrowsable(...)] — comma-separated attribute lists are extremely common in .NET DTOs, EF entities, and serialization-heavy code.
  • Silent. The phantom has a realistic-looking name (the attribute type name), a body range spanning the attribute line + the following method line, so it can be mistaken for a legitimate method until you notice the namespace prefix issue.
  • The bug is width-independent. Any line where an attribute consumes the entire line triggers the stripping fault; the explicit interface regex just happens to be the one that reliably capitalizes on it for comma-separated forms.

Cross-language note

C#-specific. StripLeadingCSharpAttributeLists is only called for lang == "csharp" at SymbolExtractor.cs:447. Other languages have their own attribute/annotation stripping (or don't need it).

Scope

  • src/CodeIndex/Indexer/SymbolExtractor.cs:660 — fix the final return to use line[cursor..] unconditionally (or return string.Empty when cursor >= line.Length).
  • src/CodeIndex/Indexer/SymbolExtractor.cs:116 — (optional defense) add [ to the returnType negative lookahead.
  • tests/CodeIndex.Tests/SymbolExtractorTests.cs — fixtures: comma-separated attribute list ending exactly at end-of-line must NOT produce phantom symbols.

Related

Environment

  • cdidx: v1.10.0 (/root/.local/bin/cdidx)
  • Platform: Linux 4.4.0
  • Fixture inline in Repro section.

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