diff --git a/CHANGELOG.md b/CHANGELOG.md index 29985f060..8d2c9e7b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- In C#, a bare name like `TestContext`, `Easing` or `Helper()` now resolves only to a member of the class it's written in, its outer classes, their base classes, or a type brought in with `using static`. That includes a `global using static` and a project file's ``. On eShop, test classes' `TestContext` used to land on one other test class's property; on Newtonsoft.Json, `TypeNameHandling.All` now reaches the enum rather than a same-named property. - In Objective-C, a message now reaches a method only when it could really be sent there. A message to `self` or `super` reaches only the sender's class and its superclasses, including categories on UIKit and AppKit views. A C-style call like `completionBlock()` is never taken for a method or property. A message to a local variable uses the variable's declared type (`FMResultSet *rs`). On SDWebImage, `[self class]`, `respondsToSelector:` and `isKindOfClass:` used to land on a proxy class's overrides over 150 times, and on fmdb `[rs close]` now reaches the result set. - In VB.NET, a call through a receiver like `Me.Panel.Controls.Add(…)` or `New System.Drawing.Size(…)` no longer lands on an unrelated class's member with the same name. It now resolves only through `Me`, `MyBase` or `MyClass`, through the member's own class or module (`Logger.Log(…)`), or through a service lookup's type (`GetService(Of Notifier).Notify()`). On SCrawler, the Windows Forms designer code used to link over 700 `Size` calls to a nested enum's case and over 500 `Controls.Add` calls to a collection class. - In CFML, a call no longer lands on an unrelated component's method just because the name matches. A call with no receiver inside a component now reaches only that component's methods and those of the components it extends, so built-ins like `now()` stay built-ins. A call on an untyped variable now needs the variable to be named after the component, so `server.keyExists(…)` is no longer taken for some component's `keyExists`. On coldbox, `now()` used to land on a date helper over 200 times, and TestBox's `fail(…)` in specs now reaches the spec base class. diff --git a/__tests__/csharp-bare-member-scope.test.ts b/__tests__/csharp-bare-member-scope.test.ts new file mode 100644 index 000000000..ed604d12a --- /dev/null +++ b/__tests__/csharp-bare-member-scope.test.ts @@ -0,0 +1,93 @@ +/** + * A bare C# name — `TestContext`, `Easing`, `Helper()` — is a member of the + * types around it, of their base types, or of a type a static using brings in + * (in the file, as `global using static`, or as a project file's + * ``). eShop's `TestContext.Current` in one test class + * went to another test class's `TestContext` property; MAUI's `Easing.Linear` + * to an animation class's `Easing` property. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-csharp-scope-')); + const files: Record = { + 'tests/OrderProcessorTests.cs': `namespace App.Tests; + +public class OrderProcessorTests +{ + public TestContext TestContext { get; set; } = null!; +} +`, + 'src/Animations/AnimationBase.cs': `namespace App.Animations; + +public abstract class AnimationBase +{ + public EasingType Easing { get; set; } +} +`, + 'src/Execution/ExpressionBuilder.cs': `namespace App.Execution; + +public static class ExpressionBuilder +{ + public static object Call(object target) => target; +} +`, + 'src/App.csproj': ` + + + + +`, + 'src/Specs/SpecBase.cs': `namespace App.Specs; + +public abstract class SpecBase +{ + protected void AssertValid() { } +} +`, + 'src/Specs/CatalogSpec.cs': `namespace App.Specs; + +public class CatalogSpec : SpecBase +{ + public void Runs() + { + var token = TestContext.Current.CancellationToken; + var easing = Easing.Linear; + AssertValid(); + Call(token); + } +} +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('bare C# names', () => { + it('reach the base class and a project-wide static using, never another class’s property', () => { + const ids = cg.getNodesInFile('src/Specs/CatalogSpec.cs').map((n) => n.id); + const targets = cg + .getOutgoingEdgesFrom(ids) + .filter((e) => e.kind === 'calls' || e.kind === 'references') + .map((e) => cg.getNode(e.target)!.qualifiedName); + expect(targets).not.toContain('App.Tests::OrderProcessorTests::TestContext'); + expect(targets).not.toContain('App.Animations::AnimationBase::Easing'); + expect(targets).toContain('App.Specs::SpecBase::AssertValid'); + expect(targets).toContain('App.Execution::ExpressionBuilder::Call'); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index d65b03fd0..9202c43ed 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -4,6 +4,7 @@ * Handles symbol name matching for reference resolution. */ +import * as fs from 'fs'; import * as path from 'path'; import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext, isSupertypeTarget, CPP_DEFINE_SIGNATURE, isInheritanceRef, isImportableKind } from './types'; @@ -1431,6 +1432,123 @@ function dartHeadOf(decl: Node, context: ResolutionContext): { supers: string[]; }; } +const CSHARP_TYPE_KINDS: ReadonlySet = new Set(['class', 'interface', 'enum', 'struct', 'record']); +const CSHARP_MEMBER_KINDS: ReadonlySet = new Set(['method', 'property', 'field', 'enum_member', 'constant', 'event']); +const CSHARP_SUPERS = new WeakMap>(); +const CSHARP_STATIC_USINGS = new WeakMap>>(); + +/** + * Whether a bare C# name — `TestContext`, `Easing`, `Helper()` — can mean the + * member `n`: C# reads a bare name as a member of the types around it (outer + * classes included) or of their base types, or of a `using static` type. + * Never some unrelated class's: eShop's `TestContext.CancellationToken` in + * one test class went to another test class's `TestContext` property, MAUI's + * `Easing.Linear` to an animation class's `Easing`. Chain links the line + * shows a receiver for are not judged. + */ +function isCsharpMemberInScope(n: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { + if (!CSHARP_MEMBER_KINDS.has(n.kind)) return true; + const cut = n.qualifiedName.lastIndexOf('::'); + if (cut < 0 || !hasNoReceiverOnLine(ref, context)) return true; + const owner = n.qualifiedName.slice(0, cut).split(/::|\./).pop()!; + if (csharpStaticUsings(ref.filePath, context).has(owner)) return true; + const around = context + .getNodesInFile(ref.filePath) + .filter((t) => CSHARP_TYPE_KINDS.has(t.kind) && t.startLine <= ref.line && t.endLine >= ref.line); + // No type around the name: the declaration wasn't recovered — nothing to judge by. + if (around.length === 0) return true; + const seen = new Set(); + const queue = around.map((t) => t.name); + while (queue.length > 0 && seen.size < 40) { + const name = queue.shift()!; + if (seen.has(name)) continue; + seen.add(name); + if (name === owner) return true; + queue.push(...csharpSupertypesOf(name, context)); + } + return false; +} + +/** The simple names a C# type's declarations (every `partial` one) derive from. */ +function csharpSupertypesOf(typeName: string, context: ResolutionContext): string[] { + let memo = CSHARP_SUPERS.get(context); + if (!memo) CSHARP_SUPERS.set(context, (memo = new Map())); + const hit = memo.get(typeName); + if (hit) return hit; + const names: string[] = []; + for (const decl of context.getNodesByName(typeName)) { + if (decl.language !== 'csharp' || !CSHARP_TYPE_KINDS.has(decl.kind)) continue; + const lines = context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split(/\r?\n/) ?? []; + let head = lines.slice(decl.startLine - 1, decl.startLine + 8).join(' '); + head = head.slice(0, (head.indexOf('{') + 1 || head.length + 1) - 1); + let depth = 0; + let flat = ''; + for (const ch of head) { + if (ch === '<' || ch === '(') depth++; + else if (ch === '>' || ch === ')') depth = Math.max(0, depth - 1); + else if (depth === 0) flat += ch; + } + const bases = new RegExp(`\\b${typeName}\\s*:\\s*(.*?)(?:\\bwhere\\b|$)`).exec(flat)?.[1] ?? ''; + for (const m of bases.matchAll(/([A-Za-z_][\w.]*)/g)) names.push(m[1]!.split('.').pop()!); + } + memo.set(typeName, names); + return names; +} + +/** + * The types a C# file sees through static usings: its own `using static + * A.B.Type;`, any file's `global using static`, and `` in the `.csproj` / `Directory.Build.props` files above it + * (AutoMapper imports its ExpressionBuilder helpers project-wide that way). + */ +function csharpStaticUsings(file: string, context: ResolutionContext): Set { + let memo = CSHARP_STATIC_USINGS.get(context); + if (!memo) CSHARP_STATIC_USINGS.set(context, (memo = new Map())); + const hit = memo.get(file); + if (hit) return hit; + const owners = new Set(csharpProjectStaticUsings(path.posix.dirname(file), context, memo)); + for (const m of (context.readFile(file) ?? '').matchAll(/^\s*(?:global\s+)?using\s+static\s+([\w.]+)\s*;/gm)) owners.add(m[1]!.split('.').pop()!); + memo.set(file, owners); + return owners; +} + +/** Static usings that apply to every file under `dir`: project files on the way up, and every `global using static`. */ +function csharpProjectStaticUsings(dir: string, context: ResolutionContext, memo: Map>): Set { + const key = `dir:${dir}`; + const hit = memo.get(key); + if (hit) return hit; + let owners: Set; + if (dir === '.' || dir === '' || dir === '/') { + owners = new Set(); + for (const f of context.getAllFiles()) { + if (!f.endsWith('.cs')) continue; + const text = context.readFile(f) ?? ''; + if (!text.includes('global using static')) continue; + for (const m of text.matchAll(/^\s*global\s+using\s+static\s+([\w.]+)\s*;/gm)) owners.add(m[1]!.split('.').pop()!); + } + } else { + owners = new Set(csharpProjectStaticUsings(path.posix.dirname(dir), context, memo)); + } + let entries: string[] = []; + try { + entries = fs.readdirSync(path.join(context.getProjectRoot(), dir === '.' ? '' : dir)); + } catch { + entries = []; + } + for (const entry of entries) { + if (!/\.(?:csproj|props)$/i.test(entry)) continue; + let text = ''; + try { + text = fs.readFileSync(path.join(context.getProjectRoot(), dir === '.' ? '' : dir, entry), 'utf8'); + } catch { + continue; + } + for (const m of text.matchAll(/]*\bStatic\s*=\s*"true"/gi)) owners.add(m[1]!.split('.').pop()!); + } + memo.set(key, owners); + return owners; +} + const OBJC_SUPERS = new WeakMap>(); const OBJC_MEMBER_KINDS: ReadonlySet = new Set(['method', 'property', 'field']); @@ -2250,8 +2368,10 @@ export function matchByExactName( ? vbReceiverOf(ref, context) : null; const objcShape = ref.language === 'objc' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*:*(?:\w+:)*$/.test(ref.referenceName) ? objcCallShape(ref, context) : null; + const csharpBare = ref.language === 'csharp' && (ref.referenceKind === 'calls' || ref.referenceKind === 'references') && /^[A-Za-z_]\w*$/.test(ref.referenceName); const phpSelf = phpSelfReceiver(ref, context); const filtered = sameName.filter((n) => + !(csharpBare && !isCsharpMemberInScope(n, ref, context)) && !(objcShape === 'c-call' && OBJC_MEMBER_KINDS.has(n.kind)) && !(objcShape === 'self-send' && !isObjcSelfSendTarget(n, ref, context)) && !(vbReceiver !== null && !isVbMemberReachable(n, vbReceiver)) && @@ -3376,6 +3496,8 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { RUBY_ANCESTRY.delete(context); CFML_CHAINS.delete(context); OBJC_SUPERS.delete(context); + CSHARP_SUPERS.delete(context); + CSHARP_STATIC_USINGS.delete(context); LUA_LOCALS.delete(context); PHP_FILE_SCOPES.delete(context); JAVA_STATIC_IMPORTS.delete(context); @@ -5377,6 +5499,7 @@ export function matchFuzzy( ? vbReceiverOf(ref, context) : null; const objcShape = ref.language === 'objc' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*:*(?:\w+:)*$/.test(ref.referenceName) ? objcCallShape(ref, context) : null; + const csharpBare = ref.language === 'csharp' && (ref.referenceKind === 'calls' || ref.referenceKind === 'references') && /^[A-Za-z_]\w*$/.test(ref.referenceName); const phpSelf = phpSelfReceiver(ref, context); // Names are case-sensitive in every language but a handful: Rust's // `Bytes` is not the method `bytes`, Python's builtin `dir(…)` not a class @@ -5398,6 +5521,7 @@ export function matchFuzzy( !(vbReceiver !== null && !isVbMemberReachable(n, vbReceiver)) && !(objcShape === 'c-call' && OBJC_MEMBER_KINDS.has(n.kind)) && !(objcShape === 'self-send' && !isObjcSelfSendTarget(n, ref, context)) && + !(csharpBare && !isCsharpMemberInScope(n, ref, context)) && !(phpSelf && (n.kind !== 'method' || !isPhpMethodInScope(n, ref, phpSelf, context)))) .filter((n) => (ref.referenceKind !== 'references' && ref.referenceKind !== 'function_ref') || sameLanguageFamily(n.language, ref.language));