Skip to content

csharp: case-label and when-guard pattern variables must scope to the switch section, not the switch body #724

Description

@zzet

Follow-up from the review of #677. csharpScopeFormers gained switch_body in place of switch_section. That is right for one kind of binder and wrong for the other, because C# has two switch scoping rules that coexist.

Ground truth

Both of these compile clean under Roslyn (dotnet 9.0.305, net9.0):

// (1) sibling sections may redeclare the same pattern variable
//     => a case-label / `when`-guard pattern variable is SECTION-scoped
public int SiblingSectionPatternVar(object o) {
    switch (o) {
        case int x: return x;
        case string x: return x.Length;
        default: return 0;
    }
}

// (2) a local declared in one section is alive in its siblings
//     => an ordinary local is BODY-scoped (redeclaring it is CS0128)
public int SiblingSectionLocal(int k) {
    switch (k) {
        case 1: int y = 1; return y;
        default: y = 2; return y;
    }
}

A third case pins the boundary: a pattern variable introduced by an if inside a section does escape to the switch body — Roslyn reports CS0165: Use of unassigned local variable, i.e. it resolves to the local and is merely not definitely assigned, rather than falling back to a field. So the discriminator is not the ancestor node type; it is where in the section the binder sits.

Effect

Because everything now stops at switch_body, a case-label pattern variable shadows the entire switch body and eats a same-named field use in a sibling section.

namespace App {
    public class Repo { public int Get(int id) { return id; } }
    public class Flow {
        private readonly Repo repo;
        public int Run(object o, int k) {
            switch (k) {
                case 1 when o is Repo repo: return repo.Get(1);
                default: return repo.Get(2);   // `repo` here is the FIELD
            }
        }
    }
}

reads edges to unresolved::*.repo from Flow.Run:

revision field reads correct?
f9e3f442 (merge base) 2 no — one is the label's own local, a false positive
1a939e45 (previous head) 1 yes
786a3929 (merged head) 0 no — the real field read is gone

A regression against both the merge base and the previous head. An independent 20-fixture construct sweep across the same three revisions found this to be the only construct-level difference between the previous head and the merged head.

Fix

Decide by position within the section: label territory is everything before the section's first statement.

--- a/internal/parser/languages/csharp.go
+++ b/internal/parser/languages/csharp.go
@@ func csharpLocalScopeOf(n *sitter.Node) csharpLocalScope {
 	for cur := n; cur != nil; cur = cur.Parent() {
+		// A switch SECTION is not a declaration space for the locals its
+		// statement list declares (those live in the switch BODY), but it
+		// IS one for a pattern variable bound in a case label or a `when`
+		// guard. Roslyn accepts `case int x:` beside `case string x:` in a
+		// sibling section AND accepts `case 1: int y = 1;` / `default: y =
+		// 2;`, so the two rules coexist and the answer depends on where in
+		// the section the binder sits: label territory is everything
+		// before the section's first statement.
+		if cur.Type() == "switch_section" && csharpInSwitchLabel(cur, n) {
+			return csharpLocalScope{start: int(cur.StartByte()), end: int(cur.EndByte())}
+		}
 		if csharpScopeFormers[cur.Type()] {
 			return csharpLocalScope{start: int(cur.StartByte()), end: int(cur.EndByte())}
 		}
 	}
 	return csharpLocalScope{start: 0, end: math.MaxInt}
 }
 
+// csharpInSwitchLabel reports whether binder sits in section's LABEL
+// region - before the first statement the section lists.
+func csharpInSwitchLabel(section, binder *sitter.Node) bool {
+	for i, _nc := 0, int(section.NamedChildCount()); i < _nc; i++ {
+		c := section.NamedChild(i)
+		if c == nil {
+			continue
+		}
+		t := c.Type()
+		if strings.HasSuffix(t, "_statement") || t == "block" {
+			return binder.StartByte() < c.StartByte()
+		}
+	}
+	return false
+}

Restores the correct answer (1 field read), keeps TestResolveCSharpTypedLocal_AliveAcrossSwitchSections and TestResolveCSharpTypedLocal_SiblingScopeRedeclarationKeepsItsRecord passing, and leaves 3,525 tests green in internal/parser/languages + internal/resolver and 1,612 in internal/indexer + internal/indexer/merkle.

Note switch_body is a real node in the vendored tree-sitter-c-sharp v0.23.5 grammar, so the current entry is live, not inert — the table just cannot express both rules on its own.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions