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.
Follow-up from the review of #677.
csharpScopeFormersgainedswitch_bodyin place ofswitch_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 (
dotnet9.0.305,net9.0):A third case pins the boundary: a pattern variable introduced by an
ifinside a section does escape to the switch body — Roslyn reportsCS0165: 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.readsedges tounresolved::*.repofromFlow.Run:f9e3f442(merge base)1a939e45(previous head)786a3929(merged head)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.
Restores the correct answer (1 field read), keeps
TestResolveCSharpTypedLocal_AliveAcrossSwitchSectionsandTestResolveCSharpTypedLocal_SiblingScopeRedeclarationKeepsItsRecordpassing, and leaves 3,525 tests green ininternal/parser/languages+internal/resolverand 1,612 ininternal/indexer+internal/indexer/merkle.Note
switch_bodyis a real node in the vendoredtree-sitter-c-sharpv0.23.5 grammar, so the current entry is live, not inert — the table just cannot express both rules on its own.