mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
fix(vbnet): a member access resolves through what the line names (#2149)
VB.NET's extractor keeps a call's last name only, so `Me.Panel.Controls.Add(x)` and `New System.Drawing.Size(1, 2)` arrived as bare `Add` / `Size` and exact / fuzzy matching took any class's member (even an enum case). The receiver is now read from the line: a member candidate needs `Me` / `MyBase` / `MyClass`, its own type or module as the last receiver segment, or a `(Of T)` type argument (`GetService(Of Notifier).Notify()`). Genuinely bare names are unchanged. A/B (edges removed / added): SCrawler -3452/+6, remoteapptool -143/+0, CompactGUI -31/+5, ViVeTool-GUI -1, TaskbarX 0. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Fixes
|
||||
|
||||
- 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.
|
||||
- In Ruby, a call no longer lands on an unrelated class's method just because it's the only method with that name. A call on an untyped receiver now needs the receiver to be named after the method's class, like `web_push_request.legacy_encrypt`. A call with no receiver inside a class now reaches only that class's methods, its superclasses' methods, and those of the modules it includes, extends or prepends. On rubocop, `node.loc` on a syntax node used to land on the project's one `loc` method over 1,200 times, and Kernel's `format(…)` on the language server's `format`. On mastodon, `render` in controllers used to land on an inline renderer, and `authorize` now reaches the authorization concern.
|
||||
- In Kotlin, a call to a top-level or extension function now resolves only to one the file can see: one in its own package, one it imports by name, or one from a package it imports with `*`. On Exposed, tests that imported kotlin.test's or JUnit's `assertEquals`, or the JDBC suite's helper, used to land on the R2DBC suite's helper over 3,000 times. On okhttp, tests that import `mockwebserver3.MockResponse` used to construct the deprecated class of the same name. A function type with a qualified receiver, like `(DatabaseConfig.Builder.() -> Unit)?`, no longer breaks the parse of the class around it. Re-index after upgrading.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* VB.NET's extractor keeps only the last name of a member access, so
|
||||
* `Me.Panel.Controls.Add(x)` and `New System.Drawing.Size(1, 2)` reach the
|
||||
* resolver as bare `Add` / `Size`. A member reached that way must match what
|
||||
* the line writes before the dot: `Me` / `MyBase` / `MyClass`, the member's
|
||||
* own type or module (`Logger.Log(…)`), or a service locator's type argument
|
||||
* (`GetService(Of Notifier).Notify()`). SCrawler's designer code sent
|
||||
* `New System.Drawing.Size(…)` to a nested enum's `Size` case 713 times and
|
||||
* `Controls.Add(…)` to a collection class's `Add` 547 times.
|
||||
*/
|
||||
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-vb-member-'));
|
||||
const files: Record<string, string> = {
|
||||
'Editors/UsersInfoForm.vb': `Namespace Editors
|
||||
Friend Class UsersInfoForm
|
||||
Private Enum EComparers
|
||||
Name
|
||||
Size
|
||||
End Enum
|
||||
End Class
|
||||
End Namespace
|
||||
`,
|
||||
'Collections/DataColorCollection.vb': `Friend Class DataColorCollection
|
||||
Friend Sub Add(ByVal Item As Object)
|
||||
End Sub
|
||||
End Class
|
||||
`,
|
||||
'Services/Notifier.vb': `Public Class Notifier
|
||||
Public Sub Notify(ByVal Text As String)
|
||||
End Sub
|
||||
End Class
|
||||
`,
|
||||
'Logger.vb': `Public Module Logger
|
||||
Public Sub Log(ByVal Text As String)
|
||||
End Sub
|
||||
End Module
|
||||
`,
|
||||
'MainForm.vb': `Public Class MainForm
|
||||
Private Sub InitializeComponent()
|
||||
Me.Panel.Controls.Add(Me.Button1)
|
||||
Me.Panel.Size = New System.Drawing.Size(184, 25)
|
||||
Logger.Log("ready")
|
||||
Application.GetService(Of Notifier).Notify("ready")
|
||||
Me.Refresh()
|
||||
End Sub
|
||||
|
||||
Private Sub Refresh()
|
||||
End Sub
|
||||
End Class
|
||||
`,
|
||||
};
|
||||
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('VB.NET member access', () => {
|
||||
it('reaches a member only through what the line names', () => {
|
||||
const ids = cg.getNodesInFile('MainForm.vb').map((n) => n.id);
|
||||
const targets = cg
|
||||
.getOutgoingEdgesFrom(ids)
|
||||
.filter((e) => e.kind === 'calls')
|
||||
.map((e) => cg.getNode(e.target)!.qualifiedName)
|
||||
.sort();
|
||||
expect(targets).not.toContain('Editors::UsersInfoForm::EComparers::Size');
|
||||
expect(targets).not.toContain('DataColorCollection::Add');
|
||||
expect(targets).toContain('Logger::Log');
|
||||
expect(targets).toContain('Notifier::Notify');
|
||||
expect(targets).toContain('MainForm::Refresh');
|
||||
});
|
||||
});
|
||||
@@ -1431,6 +1431,53 @@ function dartHeadOf(decl: Node, context: ResolutionContext): { supers: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const VB_MEMBER_KINDS: ReadonlySet<string> = new Set(['method', 'property', 'field', 'enum_member', 'constant', 'variable']);
|
||||
|
||||
/**
|
||||
* What a VB.NET member access is written on, read at the call site: the
|
||||
* extractor keeps a call's last name only, so `Me.CMB.Buttons.Add(x)` and
|
||||
* `New System.Drawing.Size(1, 2)` arrive as bare `Add` / `Size`. Returns null
|
||||
* for a genuinely bare name, `''` for a `With` block's `.Name`, else the
|
||||
* text before the dot (`Me.CMB.Buttons`, `System.Drawing`).
|
||||
*/
|
||||
function vbReceiverOf(ref: UnresolvedRef, context: ResolutionContext): string | null {
|
||||
const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1];
|
||||
if (line === undefined) return null;
|
||||
const lower = line.toLowerCase();
|
||||
const name = ref.referenceName.toLowerCase();
|
||||
let start = lower.startsWith(name, ref.column) ? ref.column : -1;
|
||||
if (start < 0) {
|
||||
const m = new RegExp(`(?<![\\w])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w])`).exec(lower);
|
||||
start = m ? m.index : -1;
|
||||
}
|
||||
if (start < 0) return null;
|
||||
const before = line.slice(0, start);
|
||||
const dot = /([\w.()]*?)\s*\.\s*$/.exec(before);
|
||||
if (!dot) return null;
|
||||
// `GetService(Of TrayNotifierService).Notify()` — the type argument names the receiver.
|
||||
const typeArg = /\(\s*Of\s+([\w.]+)\s*\)\s*\.\s*$/i.exec(before);
|
||||
if (typeArg) return typeArg[1]!;
|
||||
return dot[1]!.replace(/\([^()]*\)/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a VB.NET member access can mean `n`: through `Me` / `MyBase` /
|
||||
* `MyClass`, or through a name that is `n`'s own type or module
|
||||
* (`Module1.Log()`, `Colors.Red`). Any other receiver has a type nothing here
|
||||
* names — SCrawler's `New System.Drawing.Size(…)` went to a nested enum's
|
||||
* `Size` case 713 times, its designer's `Controls.Add(…)` to a collection
|
||||
* class's `Add` 547.
|
||||
*/
|
||||
function isVbMemberReachable(n: Node, receiver: string): boolean {
|
||||
if (!VB_MEMBER_KINDS.has(n.kind)) return true;
|
||||
const cut = n.qualifiedName.lastIndexOf('::');
|
||||
if (cut < 0) return true;
|
||||
const last = receiver.split('.').pop()!.toLowerCase();
|
||||
if (/^(?:me|mybase|myclass)$/.test(last) && !receiver.includes('.')) return true;
|
||||
const owner = n.qualifiedName.slice(0, cut).split(/::|\./).pop()!.toLowerCase();
|
||||
return last !== '' && last === owner;
|
||||
}
|
||||
|
||||
const CFML_CHAINS = new WeakMap<ResolutionContext, Map<string, Set<string>>>();
|
||||
|
||||
/**
|
||||
@@ -2055,8 +2102,11 @@ export function matchByExactName(
|
||||
const kotlinCall = ref.language === 'kotlin' && ref.referenceKind === 'calls' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName);
|
||||
const rubyBare = ref.language === 'ruby' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*[?!]?$/.test(ref.referenceName);
|
||||
const cfmlBare = (ref.language === 'cfml' || ref.language === 'cfscript') && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*$/.test(ref.referenceName);
|
||||
const vbReceiver = ref.language === 'vbnet' && (ref.referenceKind === 'calls' || ref.referenceKind === 'instantiates') && /^\w+$/.test(ref.referenceName)
|
||||
? vbReceiverOf(ref, context) : null;
|
||||
const phpSelf = phpSelfReceiver(ref, context);
|
||||
const filtered = sameName.filter((n) =>
|
||||
!(vbReceiver !== null && !isVbMemberReachable(n, vbReceiver)) &&
|
||||
!(rubyBare && n.kind === 'method' && !isRubyMethodInScope(n, ref, context)) &&
|
||||
!(cfmlBare && n.kind === 'method' && !isCfmlMethodInScope(n, ref, context)) &&
|
||||
!(javaBare && n.kind === 'method' && !isJavaMethodInScope(n, ref, context)) &&
|
||||
@@ -5164,6 +5214,8 @@ export function matchFuzzy(
|
||||
const kotlinCall = ref.language === 'kotlin' && ref.referenceKind === 'calls' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName);
|
||||
const rubyBare = ref.language === 'ruby' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*[?!]?$/.test(ref.referenceName);
|
||||
const cfmlBare = (ref.language === 'cfml' || ref.language === 'cfscript') && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*$/.test(ref.referenceName);
|
||||
const vbReceiver = ref.language === 'vbnet' && (ref.referenceKind === 'calls' || ref.referenceKind === 'instantiates') && /^\w+$/.test(ref.referenceName)
|
||||
? vbReceiverOf(ref, context) : null;
|
||||
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
|
||||
@@ -5182,6 +5234,7 @@ export function matchFuzzy(
|
||||
!(kotlinCall && !isKotlinTopLevelVisible(n, ref, context)) &&
|
||||
!(rubyBare && n.kind === 'method' && !isRubyMethodInScope(n, ref, context)) &&
|
||||
!(cfmlBare && n.kind === 'method' && !isCfmlMethodInScope(n, ref, context)) &&
|
||||
!(vbReceiver !== null && !isVbMemberReachable(n, vbReceiver)) &&
|
||||
!(phpSelf && (n.kind !== 'method' || !isPhpMethodInScope(n, ref, phpSelf, context))))
|
||||
.filter((n) => (ref.referenceKind !== 'references' && ref.referenceKind !== 'function_ref') ||
|
||||
sameLanguageFamily(n.language, ref.language));
|
||||
|
||||
Reference in New Issue
Block a user