From b238e240e2be9bf697e0ac3a081b31fb0e319143 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 10:33:32 +0000 Subject: [PATCH] fix(objc): a message reached by name alone must be in reach (#2150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(objc): a message reached by name alone must be in reach - `[self m]` / `[super m]` / `[[self class] m]` (receiver dropped by the extractor) reach only methods of the sender's hierarchy, read from `@interface Name : Super` plus a small UIKit/AppKit/MapKit superclass table for categories on system classes. When tree-sitter-objc loses an @implementation and its methods become functions, a same-file function, one in the file named after a hierarchy class, or any when the sender's class itself was lost, still counts; `[super init]` never lands on another file's function. - C call syntax (`completionBlock()`) never means a method or property. - Local receivers are typed from ObjC declarations (`FMResultSet *rs`, `(nullable FMResultSet *)rs`, block params), and objc joins the untyped- receiver languages for the unique-name guess (`weakSelf` / `strongSelf` count as self). Shared receiver words must be 3+ letters so class prefixes (`SD`, `NS`) don't match; Ruby/CFML unchanged but one wrong lobsters edge. A/B (edges removed / added): SDWebImage -321/+47, fmdb -28/+85, CocoaLumberjack -19/+10, AFNetworking -13/+1, MBProgressHUD 0. Co-Authored-By: Claude Opus 5.5 * fix(objc): a class or typed-property receiver keeps its inherited method `[AllTypesObject objectsInRealm:…]` (a class method inherited from RLMObject) and `[managed.anyDataObj containsObject:]` (a property declared `RLMSet<…> *anyDataObj`) now keep the unique-name match when the receiver's class — or one of the property's declared types — has the method's owner in its hierarchy. realm-swift goes from -2562/+588 to -504/+529 vs main. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/objc-message-scope.test.ts | 118 ++++++++++++++++++ src/resolution/name-matcher.ts | 177 +++++++++++++++++++++++++-- 3 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 __tests__/objc-message-scope.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a7293062d..29985f060 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 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. - 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. diff --git a/__tests__/objc-message-scope.test.ts b/__tests__/objc-message-scope.test.ts new file mode 100644 index 000000000..522263bb8 --- /dev/null +++ b/__tests__/objc-message-scope.test.ts @@ -0,0 +1,118 @@ +/** + * An Objective-C message reached by name alone must be in reach: + * + * - a message to `self` / `super` (the extractor drops the receiver) is a + * method of the sender's class hierarchy — SDWebImage's `[self class]` + * went to SDWeakProxy's `class` 71 times; + * - C call syntax (`completionBlock()`) calls a block, a function or a + * function pointer, never a method or property; + * - a message to an untyped local needs its declaration (`FMResultSet *rs`) + * or a receiver named after the class — `[image respondsToSelector:]` is + * NSObject's, not the one proxy class that overrides it. + */ +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-objc-scope-')); + const files: Record = { + 'Core/SDWeakProxy.h': `#import +@interface SDWeakProxy : NSProxy +- (Class)class; +- (BOOL)respondsToSelector:(SEL)aSelector; +@end +`, + 'Core/SDWeakProxy.m': `#import "SDWeakProxy.h" +@implementation SDWeakProxy +- (Class)class { + return nil; +} +- (BOOL)respondsToSelector:(SEL)aSelector { + return NO; +} +@end +`, + 'Core/SDPrefetchToken.h': `#import +@interface SDPrefetchToken : NSObject +@property (nonatomic, copy) void (^completionBlock)(void); +@end +`, + 'Core/FMResultSet.h': `#import +@interface FMResultSet : NSObject +- (BOOL)next; +@end +`, + 'Core/FMResultSet.m': `#import "FMResultSet.h" +@implementation FMResultSet +- (BOOL)next { + return NO; +} +@end +`, + 'Core/SDBase.h': `#import +@interface SDBase : NSObject +- (void)prepare; +@end +`, + 'Core/SDBase.m': `#import "SDBase.h" +@implementation SDBase +- (void)prepare { +} +@end +`, + 'Core/SDCache.h': `#import "SDBase.h" +@interface SDCache : SDBase +- (void)storeWithCompletion:(void (^)(void))completionBlock image:(id)image; +@end +`, + 'Core/SDCache.m': `#import "SDCache.h" +#import "FMResultSet.h" +@implementation SDCache +- (void)storeWithCompletion:(void (^)(void))completionBlock image:(id)image { + Class cls = [self class]; + [super prepare]; + [self helper]; + FMResultSet *rs = [self query]; + while ([rs next]) { + } + if ([image respondsToSelector:@selector(copy)]) { + } + completionBlock(); +} +- (void)helper { +} +- (FMResultSet *)query { + return nil; +} +@end +`, + }; + 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('Objective-C messages reached by name alone', () => { + it('reach the sender’s hierarchy and typed locals; never a proxy override or a property through C call syntax', () => { + const method = cg.getNodesInFile('Core/SDCache.m').find((n) => n.name.startsWith('storeWithCompletion'))!; + const targets = cg + .getOutgoingEdges(method.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!.qualifiedName) + .sort(); + expect(targets).toEqual(['FMResultSet::next', 'SDBase::prepare', 'SDCache::helper', 'SDCache::query']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index f9f01b8eb..d65b03fd0 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1431,6 +1431,150 @@ function dartHeadOf(decl: Node, context: ResolutionContext): { supers: string[]; }; } +const OBJC_SUPERS = new WeakMap>(); +const OBJC_MEMBER_KINDS: ReadonlySet = new Set(['method', 'property', 'field']); + +/** + * How a bare Objective-C name is written at its site: `c-call` for C call + * syntax (`completionBlock()` — a function, a block or a function pointer, + * never a method), `self-send` for a message to `self` / `super` / + * `[self class]`, whose receiver the extractor drops; null otherwise. + */ +function objcCallShape(ref: UnresolvedRef, context: ResolutionContext): 'c-call' | 'self-send' | 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 name = ref.referenceName.split(':')[0]!; + if (!name) return null; + const at = new RegExp(`(? | null { + const here = context + .getNodesInFile(ref.filePath) + .filter((c) => c.kind === 'class' && c.startLine <= ref.line && c.endLine >= ref.line) + .sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0]; + if (!here) return null; + const seen = new Set(); + const queue = [here.name]; + while (queue.length > 0 && seen.size < 30) { + const name = queue.shift()!; + if (seen.has(name)) continue; + seen.add(name); + queue.push(...objcSupertypesOf(name, context)); + } + return seen; +} + +/** + * Whether a message to `self` / `super` can mean `n`: a method of a class in + * the sender's hierarchy — SDWebImage's `[self class]` went to SDWeakProxy's + * `class` 71 times. When tree-sitter-objc loses an `@implementation` + * (AFNetworking's AFURLSessionManager) its methods are indexed as functions: + * one in the sender's own file, or in the file named after a class of its + * hierarchy, still counts (as does any, when the sender's own class was lost + * too). A function elsewhere is never what `[super init]` sends to. + */ +function isObjcSelfSendTarget(n: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { + const hierarchy = objcHierarchyAt(ref, context); + if (OBJC_MEMBER_KINDS.has(n.kind)) { + const cut = n.qualifiedName.lastIndexOf('::'); + return cut < 0 || hierarchy === null || hierarchy.has(n.qualifiedName.slice(0, cut)); + } + if (n.filePath === ref.filePath || hierarchy === null) return true; + const base = n.filePath.slice(n.filePath.lastIndexOf('/') + 1).replace(/\.\w+$/, ''); + return hierarchy.has(base); +} + +/** + * UIKit / AppKit superclasses, for a category on a system class: an + * `UIImageView (WebCache)` method sending `[self sd_internalSetImageWithURL:…]` + * reaches the `UIView (WebCache)` category. + */ +const OBJC_SYSTEM_SUPERS: Readonly> = { + UIResponder: 'NSObject', UIView: 'UIResponder', UIViewController: 'UIResponder', UIWindow: 'UIView', + UIControl: 'UIView', UIButton: 'UIControl', UITextField: 'UIControl', UISwitch: 'UIControl', UISlider: 'UIControl', + UIImageView: 'UIView', UILabel: 'UIView', UIScrollView: 'UIView', UITableView: 'UIScrollView', + UICollectionView: 'UIScrollView', UITextView: 'UIScrollView', UITableViewCell: 'UIView', + UICollectionReusableView: 'UIView', UICollectionViewCell: 'UICollectionReusableView', + MKAnnotationView: 'UIView', MKMapView: 'UIView', + NSResponder: 'NSObject', NSView: 'NSResponder', NSViewController: 'NSResponder', NSWindow: 'NSResponder', + NSControl: 'NSView', NSImageView: 'NSControl', NSButton: 'NSControl', NSTextField: 'NSControl', NSTableView: 'NSControl', +}; + +/** + * Whether an Objective-C receiver's type owns `method`, in its hierarchy: the + * receiver names a class (`[AllTypesObject objectsInRealm:…]` — a class + * method inherited from RLMObject), or is a property one of whose + * `@property … Type *name` declarations gives such a type + * (`managed.anyDataObj` → RLMSet, for `containsObject:`). + */ +function objcReceiverReaches(receiver: string, method: Node, context: ResolutionContext): boolean { + const cut = method.qualifiedName.lastIndexOf('::'); + if (cut < 0) return false; + const owner = method.qualifiedName.slice(0, cut); + let types: string[] = []; + if (/^[A-Z]\w*$/.test(receiver)) { + if (context.getNodesByName(receiver).some((n) => n.kind === 'class' && n.language === 'objc')) types = [receiver]; + } else if (receiver.includes('.')) types = objcPropertyTypes(receiver.split('.').pop()!, context); + const seen = new Set(); + const queue = [...types]; + 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(...objcSupertypesOf(name, context)); + } + return false; +} + +/** The classes the `@property … Type *name` declarations of `name` give. */ +function objcPropertyTypes(name: string, context: ResolutionContext): string[] { + const types = new Set(); + const decl = new RegExp(`@property\\s*(?:\\([^)]*\\)\\s*)?([A-Z]\\w*)\\s*(?:<[^;]*>\\s*)?\\*\\s*(?:_Nullable\\s+|_Nonnull\\s+)?${name}\\b`); + for (const n of context.getNodesByName(name)) { + if (n.kind !== 'property' || n.language !== 'objc') continue; + const line = context.getFileLines?.(n.filePath)?.[n.startLine - 1] ?? context.readFile(n.filePath)?.split('\n')[n.startLine - 1] ?? ''; + const t = decl.exec(line)?.[1]; + if (t) types.add(t); + } + return [...types]; +} + +/** The superclasses an Objective-C class's `@interface` declarations name. */ +function objcSupertypesOf(name: string, context: ResolutionContext): string[] { + let memo = OBJC_SUPERS.get(context); + if (!memo) OBJC_SUPERS.set(context, (memo = new Map())); + const hit = memo.get(name); + if (hit) return hit; + const supers: string[] = []; + for (const decl of context.getNodesByName(name)) { + if (decl.kind !== 'class' || decl.language !== 'objc') continue; + const line = context.getFileLines?.(decl.filePath)?.[decl.startLine - 1] ?? context.readFile(decl.filePath)?.split('\n')[decl.startLine - 1] ?? ''; + const sup = /@interface\s+\w+\s*:\s*(\w+)/.exec(line)?.[1]; + if (sup && !supers.includes(sup)) supers.push(sup); + } + const system = OBJC_SYSTEM_SUPERS[name]; + if (supers.length === 0 && system) supers.push(system); + memo.set(name, supers); + return supers; +} + const VB_MEMBER_KINDS: ReadonlySet = new Set(['method', 'property', 'field', 'enum_member', 'constant', 'variable']); /** @@ -2104,8 +2248,12 @@ export function matchByExactName( 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 objcShape = ref.language === 'objc' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*:*(?:\w+:)*$/.test(ref.referenceName) + ? objcCallShape(ref, context) : null; const phpSelf = phpSelfReceiver(ref, context); const filtered = sameName.filter((n) => + !(objcShape === 'c-call' && OBJC_MEMBER_KINDS.has(n.kind)) && + !(objcShape === 'self-send' && !isObjcSelfSendTarget(n, ref, context)) && !(vbReceiver !== null && !isVbMemberReachable(n, vbReceiver)) && !(rubyBare && n.kind === 'method' && !isRubyMethodInScope(n, ref, context)) && !(cfmlBare && n.kind === 'method' && !isCfmlMethodInScope(n, ref, context)) && @@ -3227,6 +3375,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { KOTLIN_FILE_SCOPES.delete(context); RUBY_ANCESTRY.delete(context); CFML_CHAINS.delete(context); + OBJC_SUPERS.delete(context); LUA_LOCALS.delete(context); PHP_FILE_SCOPES.delete(context); JAVA_STATIC_IMPORTS.delete(context); @@ -3302,6 +3451,13 @@ function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[] new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_][\\w.]*)`), // = new Logger() new RegExp(`\\b([A-Z][\\w.]*)\\s+${r}\\b\\s*[=;,)]`), // Logger lg; / param ]; + case 'objc': + return [ + // FMResultSet *rs = … / NSString * _Nullable name; / a block's ^(FMResultSet *rs) + new RegExp(`\\b([A-Z]\\w*)\\s*(?:<[^<>;]*>\\s*)?\\*\\s*(?:(?:_Nullable|_Nonnull|__strong|__weak|__unsafe_unretained|const)\\s+)*${r}\\b(?!\\s*\\()`), + // a method parameter: - (void)read:(nullable FMResultSet *)rs + new RegExp(`\\(\\s*(?:(?:nullable|nonnull|__kindof)\\s+)*([A-Z]\\w*)\\s*(?:<[^<>)]*>\\s*)?\\*[^)]*\\)\\s*${r}\\b`), + ]; case 'swift': return [ new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`), // let lg = Logger(...) @@ -4216,8 +4372,9 @@ export function matchMethodCall( // `node.loc` on a rubocop-ast node went to the one `loc` in the project // 1,201 times; lobsters' `value.to_s` to a short-id class's. if (targetMethods.length === 1 && !narrowed && targetMethods[0]!.language === ref.language && - !(UNTYPED_RECEIVER_LANGUAGES.has(ref.language) && !/^(?:self|self\.class|this|super)$/i.test(objectOrClass!) && - !sharesReceiverWord(objectOrClass!, targetMethods[0]!))) { + !(UNTYPED_RECEIVER_LANGUAGES.has(ref.language) && !/^(?:self|self\.class|this|super|weak_?self|strong_?self)$/i.test(objectOrClass!) && + !sharesReceiverWord(objectOrClass!, targetMethods[0]!) && + !(ref.language === 'objc' && objcReceiverReaches(objectOrClass!, targetMethods[0]!, context)))) { return { original: ref, targetNodeId: targetMethods[0]!.id, @@ -5022,15 +5179,16 @@ function hasParameterBinding(code: string, escapedName: string): boolean { /** * Languages whose receivers nothing types, where a unique method name alone * is no evidence: CFML's `server.keyExists()` is the struct member function, - * not the one component method named `keyExists`. + * not the one component method named `keyExists`; Objective-C's + * `image.respondsToSelector:` is NSObject's, not a proxy class's override. */ -const UNTYPED_RECEIVER_LANGUAGES: ReadonlySet = new Set(['ruby', 'cfml', 'cfscript']); +const UNTYPED_RECEIVER_LANGUAGES: ReadonlySet = new Set(['ruby', 'cfml', 'cfscript', 'objc']); /** * Whether a receiver is named after the owner of `method`, case aside: the * receiver's last segment is the owner's name (`cbsecurity` → CBSecurity), or - * they share a word (`web_push_request` → WebPushRequest, `executor1` → - * Executor). + * they share a word of three letters or more (`web_push_request` → + * WebPushRequest, `executor1` → Executor, `decodedImage` → UIImage). */ function sharesReceiverWord(receiver: string, method: Node): boolean { const cut = method.qualifiedName.lastIndexOf('::'); @@ -5038,7 +5196,8 @@ function sharesReceiverWord(receiver: string, method: Node): boolean { const ownerQn = method.qualifiedName.slice(0, cut); const flat = (w: string) => w.replace(/[^A-Za-z0-9]/g, '').replace(/\d+$/, '').toLowerCase(); if (flat(receiver.split('.').pop()!) === flat(ownerQn.split(/::|\./).pop()!)) return true; - const owner = new Set(splitCamelCase(ownerQn).map(flat)); + // Two-letter words are class prefixes (`SD`, `NS`, `UI`), not names. + const owner = new Set(splitCamelCase(ownerQn).map(flat).filter((w) => w.length > 2)); return splitCamelCase(receiver).some((w) => owner.has(flat(w))); } @@ -5216,6 +5375,8 @@ export function matchFuzzy( 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 objcShape = ref.language === 'objc' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*:*(?:\w+:)*$/.test(ref.referenceName) + ? objcCallShape(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 @@ -5235,6 +5396,8 @@ export function matchFuzzy( !(rubyBare && n.kind === 'method' && !isRubyMethodInScope(n, ref, context)) && !(cfmlBare && n.kind === 'method' && !isCfmlMethodInScope(n, ref, context)) && !(vbReceiver !== null && !isVbMemberReachable(n, vbReceiver)) && + !(objcShape === 'c-call' && OBJC_MEMBER_KINDS.has(n.kind)) && + !(objcShape === 'self-send' && !isObjcSelfSendTarget(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));