From 70783f31f1e57a8cf2617c937cbe725e8c9839cb Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 20 Oct 2026 16:35:36 +0000 Subject: [PATCH] fix(objc): a message to an ivar or a property reaches the class it is declared as (#2703) An Objective-C message to a local or a parameter was typed by its declaration (`FMResultSet *rs` makes `[rs close]` FMResultSet's). One to an instance variable or a property was not, since that declaration sits outside the calling method: in a header's @interface, an @implementation's ivar block, a superclass, or synthesized for a property. The receiver's name decided instead. ObjFW's tar tool's `[_archive nextEntry]` reached OFLHAArchive::nextEntry, and React Native's `[_displayLink invalidate]` on a CADisplayLink reached RN's own RCTDisplayLink::invalidate. - objcFileMembers reads what each class declares, per file: the ivar block right after an @interface head (seen by subclasses; the head may go on over several lines, a protocol list on a line of its own), a class extension's or an @implementation's ivar block (the class's own), @property declarations, including ObjFW's two-line ones and IBOutlet ones, and @synthesize lines. Types take protocol lists, type arguments and ObjFW's OF_GENERIC(...). - objcMemberReceiverType, after the local inferrer, follows the language. `self.name` is a property of the class or a superclass. A bare name is an ivar: the class's own, one its own property synthesizes (`_name`, never a category property's), one @synthesize names, or a superclass's @interface ivar. A bare name is never a property's: `arcs` in `initWithArcs: (...)arcs` is the parameter. A capitalized bare name is a class, and `self.name` needs a property node, which skips most lookups. The type then takes the typed path (resolveMethodOnType at 0.9), and a class from outside the project has no project method, as for locals. - objcClassFiles: when more than one file declares `@interface Name` itself, those are namesakes in separate targets, and only the ones in the calling file's directory count. SDWebImage's TV demo ViewController was otherwise typed by the macOS demo's `NSImageView *imageView1`. - localReceiverTypePatterns('objc'): a local's or a parameter's type may carry OF_GENERIC(...), like `OFMutableArray OF_GENERIC(OFNumber *) *arcs`. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/objc-typed-receivers.test.ts | 253 +++++++++++++++++++++++++ src/resolution/name-matcher.ts | 216 ++++++++++++++++++++- 3 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 __tests__/objc-typed-receivers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bf7d5e3e..3a2c87ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -186,6 +186,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Objective-C, and in the C and C++ headers that sit beside it, an enum declared with Foundation's macros, like `typedef NS_ENUM(NSInteger, SDImageCacheType) { … };`, `NS_OPTIONS`, `NS_CLOSED_ENUM`, `NS_ERROR_ENUM` or CoreFoundation's `CF_ENUM` and `CF_OPTIONS`, is now indexed with its values. Before, the parser read the macro as a broken typedef, so these enums were missing (SDWebImage had none at all) and a type alias named after the first value stood in their place. A Swift annotation naming such an enum, like KSCrash's `let unwindMethods: KSBacktraceUnwindMethods`, now links to it. Re-index Objective-C, C and C++ projects after upgrading. - In Objective-C, the doc comment above a method, function or static written inside an `@implementation` is now read as its description, as the one above an `@interface` property already was. Before, the parser's wrapper around each item of an `@implementation` hid the comment, so search and `codegraph_explore` showed these methods without their documentation. Re-index Objective-C projects after upgrading. - In Objective-C, a file-scope constant or global, like `NSString *const SDWebImageErrorDomain = @"SDWebImageErrorDomain";` or `static const NSInteger kMaxRetries = 3;`, is now indexed with its value, and so is one written inside an `@implementation`. Before, only a global declared with a bare name, like `int count;`, was, so error domains, notification names, keys and shared queues were missing from search and `codegraph_explore`. A global that a `.m` file defines is reached from other files through the header that declares it, and a call of a block parameter, like `completionHandler(nil, image)`, no longer links to a namesake that another `.m` file keeps. Re-index Objective-C projects after upgrading. +- In Objective-C, a message to an instance variable or a property now reaches a method of the class it is declared as, as a message to a local variable already did. That covers an instance variable a header, an `@implementation` or a superclass declares, the one a property synthesizes (`_name`, or the one `@synthesize` names), `self.name`, and a local or parameter whose type ObjFW writes with `OF_GENERIC(…)`. Before, such a message was matched by the receiver's name: in ObjFW's archive tool, `[_archive nextEntry]` on a tar archive reached the LHA archive's method, and in React Native, `[_displayLink invalidate]` on a `CADisplayLink` reached React Native's own `RCTDisplayLink`. A message to a variable whose class comes from outside your project, like `NSMapTable` or `CADisplayLink`, no longer links to a project method of the same name. Re-index Objective-C projects after upgrading. - In TypeScript React code, a styled-components or emotion component declared with a type argument, like `const Wrapper = styled.div` followed by its CSS, or `styled(Base)`, is now indexed as a component, as one written without a type argument already was. Before, it was indexed as a plain constant, so `codegraph callers` and impact found none of the code that renders it, and its tags could link to another file's component of the same name instead: in outline/outline, `NudeButton` and `HStack` had no callers, and `` tags linked to a different `Text` than the one each file imports. Re-index TypeScript React projects after upgrading. - `codegraph sync` and the file watcher now move a React Router route to its page when the router file loads that page lazily into a value of its own, like `const Docs = lazy(() => import('./pages/Docs'))` rendered by `} />`, and the page's file is added after the router was indexed, comes back after being deleted, or gets or changes its default export in a later edit. Before, the route kept pointing at the `Docs` declaration, or at the page's old component, until the router file itself changed or the project was re-indexed, unless the page's component happened to share the declaration's name. The same now holds for a `lazy: () => import('./pages/Docs')` route whose page switches its default export to another component. Re-index React projects after upgrading to correct routes an earlier version left behind. - `codegraph sync` and the file watcher now link a component to the JSX that renders it, like ``, when the component is added in a file with no JSX of its own, as in `export default function Team() { return null; }`. Before, the link waited for an unrelated change to a file with JSX, or for a re-index, so the new component looked unused. diff --git a/__tests__/objc-typed-receivers.test.ts b/__tests__/objc-typed-receivers.test.ts new file mode 100644 index 00000000..c301ed7c --- /dev/null +++ b/__tests__/objc-typed-receivers.test.ts @@ -0,0 +1,253 @@ +/** + * An Objective-C message to an instance variable or a property of the class + * around it is typed by that declaration, as one to a local or a parameter + * already was. The ivar is declared outside the calling method, in a + * header's `@interface`, an `@implementation`'s ivar block, a superclass, or + * synthesized for a property, so the method's own lines never typed it: + * ObjFW's `[_headers mutableCopy]` reached nothing, and RN's `[_registry + * objectForKey:]` on an `NSMapTable` reached a project class named after the + * receiver. A declared class from outside the project has no project method. + * A local or a parameter whose type ObjFW writes with `OF_GENERIC(…)` is + * typed too. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; + +describe('an Objective-C receiver declared by its class', () => { + let root = ''; + let cg: CodeGraph | undefined; + + afterEach(() => { + cg?.destroy(); + cg = undefined; + if (root) fs.rmSync(root, { recursive: true, force: true }); + root = ''; + }); + + /** Index `files` and return the methods a method's calls reach, as `Class::selector`. */ + async function indexProject(files: Record): Promise<(method: string, file?: string) => string[]> { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-objc-receivers-')); + 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); + } + const graph = (cg = await CodeGraph.init(root, { index: true })); + return (method: string, file?: string) => { + const node = graph.getNodesByName(method).find((n) => n.kind === 'method' && (!file || n.filePath === file))!; + expect(node, method).toBeDefined(); + return graph + .getOutgoingEdges(node.id) + .filter((e) => e.kind === 'calls') + .map((e) => graph.getNode(e.target)!.qualifiedName) + .sort(); + }; + } + + const collections = [ + '@interface OFDictionary : OFObject', + '- (id)mutableCopy;', + '- (id)objectForKey: (id)key;', + '@end', + '@interface OFArray : OFObject', + '- (id)mutableCopy;', + '@end', + '@interface RCTComposedViewRegistry : OFDictionary', + '- (id)objectForKey: (id)key;', + '@end', + '', + ].join('\n'); + const collectionsImpl = [ + '#import "Collections.h"', + '@implementation OFDictionary', + '- (id)mutableCopy { return self; }', + '- (id)objectForKey: (id)key { return nil; }', + '@end', + '@implementation OFArray', + '- (id)mutableCopy { return self; }', + '@end', + '@implementation RCTComposedViewRegistry', + '- (id)objectForKey: (id)key { return nil; }', + '@end', + '', + ].join('\n'); + + for (const crlf of [false, true]) { + const eol = (s: string) => (crlf ? s.replace(/\n/g, '\r\n') : s); + const label = crlf ? ' (CRLF)' : ''; + + it(`types ivars, synthesized ivars and self. properties, its own and its superclass's${label}`, async () => { + const targetsOf = await indexProject({ + 'src/Collections.h': eol(collections), + 'src/Collections.m': eol(collectionsImpl), + 'src/OFHTTPResponse.h': eol([ + '#import "Collections.h"', + '@interface OFHTTPResponse : OFObject', + ' ', + '{', + '\tOFDictionary *_headers;', + '}', + '@property (nonatomic, copy) OFArray *cookies;', + '@end', + '', + ].join('\n')), + 'src/OFHTTPServerResponse.h': eol([ + '#import "OFHTTPResponse.h"', + '@interface OFHTTPServerResponse : OFHTTPResponse ', + '/**', + ' * @brief The trailer fields.', + ' */', + '@property OF_NULLABLE_PROPERTY (copy, nonatomic)', + ' OFArray *trailerFields;', + '@end', + '', + ].join('\n')), + 'src/OFHTTPServerResponse.m': eol([ + '#import "OFHTTPServerResponse.h"', + '@interface OFHTTPServerResponse ()', + '@property (nonatomic) OFArray *pending;', + '@end', + '', + '@implementation OFHTTPServerResponse', + '{', + '\tOFArray *_trailers, *_parts;', + '\tOFDictionary *mFields;', + '}', + '@synthesize pending = mPending;', + '', + '- (id)inherited', + '{', + '\treturn [_headers mutableCopy];', + '}', + '', + '- (id)fromBlock', + '{', + '\t[_parts mutableCopy];', + '\treturn [mFields objectForKey: @"Server"];', + '}', + '', + '- (id)synthesizedByName', + '{', + '\treturn [mPending mutableCopy];', + '}', + '', + '- (id)synthesized', + '{', + '\treturn [_trailerFields mutableCopy];', + '}', + '', + '- (id)throughSelf', + '{', + '\treturn [self.cookies mutableCopy];', + '}', + '@end', + '', + ].join('\n')), + }); + expect(targetsOf('inherited')).toEqual(['OFDictionary::mutableCopy']); + expect(targetsOf('fromBlock')).toEqual(['OFArray::mutableCopy', 'OFDictionary::objectForKey:']); + expect(targetsOf('synthesizedByName')).toEqual(['OFArray::mutableCopy']); + expect(targetsOf('synthesized')).toEqual(['OFArray::mutableCopy']); + expect(targetsOf('throughSelf')).toEqual(['OFArray::mutableCopy']); + }, 60_000); + } + + it('reaches no project method through a class from outside the project', async () => { + const targetsOf = await indexProject({ + 'React/Collections.h': collections, + 'React/Collections.m': collectionsImpl, + 'React/RCTSurfaceRegistry.m': [ + '#import "Collections.h"', + '@implementation RCTSurfaceRegistry {', + ' NSMapTable *_registry;', + '}', + '- (id)surfaceForRootTag:(id)rootTag', + '{', + ' return [_registry objectForKey:rootTag];', + '}', + '@end', + '', + ].join('\n'), + }); + // NSMapTable's own objectForKey:, not the registry class's by name. + expect(targetsOf('surfaceForRootTag:')).toEqual([]); + }, 60_000); + + it('types a parameter by its own declaration, and a bare name never by a property', async () => { + const targetsOf = await indexProject({ + 'src/Collections.h': collections, + 'src/Collections.m': collectionsImpl, + 'src/OFASN1ObjectIdentifier.m': [ + '#import "Collections.h"', + '@interface OFASN1ObjectIdentifier : OFObject', + '@property (readonly, nonatomic) OFDictionary *arcs;', + '@end', + '', + '@implementation OFASN1ObjectIdentifier', + '- (instancetype)initWithArcs: (OFArray OF_GENERIC(OFNumber *) *)arcs', + '{', + '\t[arcs mutableCopy];', + '\treturn self;', + '}', + '', + '- (instancetype)initWithAnyArcs: (id)arcs', + '{', + '\t[arcs mutableCopy];', + '\treturn self;', + '}', + '@end', + '', + ].join('\n'), + }); + expect(targetsOf('initWithArcs:')).toEqual(['OFArray::mutableCopy']); + // The property is `self.arcs` or `_arcs`, never `arcs`. + expect(targetsOf('initWithAnyArcs:')).not.toContain('OFDictionary::mutableCopy'); + }, 60_000); + + it('reads the class the call means when separate apps each declare one of its name', async () => { + const viewController = (type: string) => [ + '#import "Gadgets.h"', + '@interface ViewController : OFObject', + `@property (weak, nonatomic) IBOutlet ${type} *imageView1;`, + '@end', + '', + ].join('\n'); + const targetsOf = await indexProject({ + 'Shared/Gadgets.h': [ + '@interface Widget : OFObject', + '- (void)refresh;', + '@end', + '@interface Gadget : OFObject', + '- (void)refresh;', + '@end', + '', + ].join('\n'), + 'Shared/Gadgets.m': [ + '#import "Gadgets.h"', + '@implementation Widget', + '- (void)refresh { }', + '@end', + '@implementation Gadget', + '- (void)refresh { }', + '@end', + '', + ].join('\n'), + 'Examples/Demo A/ViewController.h': viewController('Widget'), + 'Examples/Demo B/ViewController.h': viewController('Gadget'), + 'Examples/Demo B/ViewController.m': [ + '#import "ViewController.h"', + '@implementation ViewController', + '- (void)viewDidLoad', + '{', + '\t[self.imageView1 refresh];', + '}', + '@end', + '', + ].join('\n'), + }); + expect(targetsOf('viewDidLoad')).toEqual(['Gadget::refresh']); + }, 60_000); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 44eeb75d..c2feb1f5 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -7081,6 +7081,212 @@ function objcPropertyTypes(name: string, context: ResolutionContext): string[] { return [...types]; } +/** + * What one file declares of an Objective-C class: instance variables (an + * `@interface`'s ivar block, which subclasses see, or a class extension's or + * an `@implementation`'s, which they don't), properties (a category's can't + * be synthesized), and the ivar each `@synthesize` names for a property. + */ +interface ObjcClassMembers { + ivars: Map; + ownIvars: Map; + properties: Map; + synthesized: Map; +} + +/** Per context: an Objective-C file → each class it declares → what it declares of it. */ +const OBJC_MEMBER_TABLES = new WeakMap>>(); +/** Per context: class name + the calling file's directory → the files declaring that class. */ +const OBJC_CLASS_FILES = new WeakMap>(); + +// A declared class as a member declaration writes it, past qualifiers, with +// its type arguments, protocol list or ObjFW's `OF_GENERIC(…)` after it, up to +// the `*`: `OFObject *`, `OFArray OF_GENERIC(OFString *) *`, +// `IBOutlet UIImageView *`. +const OBJC_MEMBER_TYPE = String.raw`(?:(?:__strong|__weak|__unsafe_unretained|__block|const|volatile|IBOutlet|IBInspectable)\s+)*([A-Z]\w*)\s*(?:<[^;{}]*?>\s*|[A-Z][A-Z0-9_]*\s*\([^;{}]*?\)\s*)?\*`; +// An instance variable, alone or listed: `OFDictionary *_headers;`, `OFString *_host, *_path;`. +const OBJC_IVAR_RE = new RegExp(String.raw`^\s*${OBJC_MEMBER_TYPE}\s*((?:(?:_Nullable|_Nonnull|const)\s+)*\w+(?:\s*,\s*\*\s*(?:(?:_Nullable|_Nonnull|const)\s+)*\w+)*)\s*;`); +// A property, past attribute macros and its attribute list: +// `@property OF_NULLABLE_PROPERTY (readonly, nonatomic) OFString *host;`. +const OBJC_PROPERTY_RE = new RegExp(String.raw`^\s*@property\b\s*(?:[A-Z][A-Z0-9_]+\s*)*(?:\([^)]*\)\s*)?(?:(?:nullable|nonnull|null_unspecified|__kindof)\s+)*${OBJC_MEMBER_TYPE}\s*(?:(?:_Nullable|_Nonnull|const)\s+)*(\w+)`); + +/** + * What each Objective-C class `filePath` declares has, by name, with the + * class each is declared as (ObjcClassMembers): read from `@interface`s (a + * header's, a class extension's or a category's: the ivar block right after + * the head, then `@property` declarations, which may run over several lines) + * and from an `@implementation`'s ivar block and `@synthesize` lines, never a + * method body. + */ +function objcFileMembers(filePath: string, context: ResolutionContext): Map { + let memo = OBJC_MEMBER_TABLES.get(context); + if (!memo) OBJC_MEMBER_TABLES.set(context, (memo = new Map())); + const hit = memo.get(filePath); + if (hit) return hit; + const tables = new Map(); + memo.set(filePath, tables); + const lines = context.getFileLines?.(filePath) ?? context.readFile(filePath)?.split(/\r?\n/) ?? []; + const count = (text: string, re: RegExp): number => (text.match(re) ?? []).length; + let members: ObjcClassMembers | null = null; + let inInterface = false; + // `@interface Name (Category)`, or a class extension `@interface Name ()`. + let category: string | null = null; + // A class declares ivars only in a block that is the first code after its + // head: 'head' until that code (`open` counts the head's unclosed `<` and + // `(`, as in a protocol list over several lines), 'ivars' inside the block + // (`depth` braces deep), 'body' past it. + let state: 'head' | 'ivars' | 'body' = 'body'; + let open = 0; + let depth = 0; + // A `@property` declaration read so far, until its `;`. + let property: string | null = null; + for (const raw of lines) { + let line = raw.replace(/\/\/.*$/, ''); + const head = /^\s*(?:[A-Z][A-Z0-9_]*(?:\([^)]*\))?\s+)*@(interface|implementation)\s+(\w+)\s*(?:<[^>]*>\s*|[A-Z][A-Z0-9_]*\([^)]*\)\s*)?(?:\(\s*(\w*)\s*\))?/.exec(line); + if (head) { + const cls = head[2]!; + members = tables.get(cls) ?? { ivars: new Map(), ownIvars: new Map(), properties: new Map(), synthesized: new Map() }; + tables.set(cls, members); + inInterface = head[1] === 'interface'; + category = head[3] ?? null; + state = 'head'; + open = 0; + property = null; + line = line.slice(head.index + head[0].length); + } else if (/^\s*@end\b/.test(line)) { + members = null; + continue; + } + if (!members) continue; + if (state === 'head') { + // The head may go on with its superclass or protocol list on the next + // lines: ObjFW's `@interface OFKernelEventObserverTests: OTTestCase` + // over ` `. + const inHead = head !== null || open > 0 || /^\s*[<:]/.test(line); + open += count(line, /[<(]/g) - count(line, /[>)]/g); + const brace = line.indexOf('{'); + if (brace >= 0) { + state = 'ivars'; + depth = 0; + line = line.slice(brace); + } else if (inHead || open > 0 || /^\s*(?:$|\/\*|\*|#)/.test(line)) { + continue; + } else { + state = 'body'; + } + } + if (state === 'ivars') { + depth += count(line, /\{/g) - count(line, /\}/g); + if (depth <= 0) state = 'body'; + const ivar = OBJC_IVAR_RE.exec(line.replace(/^[^{]*\{/, '').replace(/\}.*$/, '')); + const ivars = inInterface && category === null ? members.ivars : members.ownIvars; + for (const name of ivar?.[2]!.split(',') ?? []) { + const bare = name.replace(/\*|_Nullable|_Nonnull|\bconst\b/g, '').trim(); + if (/^\w+$/.test(bare) && !ivars.has(bare)) ivars.set(bare, ivar![1]!); + } + continue; + } + if (!inInterface) { + const synthesize = /^\s*@synthesize\s+([^;]+);/.exec(line); + for (const item of synthesize?.[1]!.split(',') ?? []) { + const [name, ivar] = item.split('=').map((s) => s.trim()); + if (name && /^\w+$/.test(ivar ?? name)) members.synthesized.set(ivar ?? name, name); + } + continue; + } + if (property === null && !/^\s*@property\b/.test(line)) continue; + property = property === null ? line : `${property} ${line}`; + if (!property.includes(';') && property.length < 400) continue; + const declared = OBJC_PROPERTY_RE.exec(property); + property = null; + if (declared && !members.properties.has(declared[2]!)) { + members.properties.set(declared[2]!, { type: declared[1]!, category: !!category }); + } + } + return tables; +} + +/** + * The files declaring the Objective-C class `name`, the calling file first. + * When more than one file declares the class itself (`@interface Name`, not a + * category or an extension), they are namesakes in separate targets, like + * each SDWebImage demo app's `ViewController`, and only the declarations in + * the calling file's directory are the one the call means. + */ +function objcClassFiles(name: string, ref: UnresolvedRef, context: ResolutionContext): string[] { + const dir = ref.filePath.slice(0, ref.filePath.lastIndexOf('/') + 1); + let memo = OBJC_CLASS_FILES.get(context); + if (!memo) OBJC_CLASS_FILES.set(context, (memo = new Map())); + const key = `${name}\u0000${dir}`; + const hit = memo.get(key); + if (hit) return hit; + const decls = context.getNodesByName(name).filter((n) => n.kind === 'class' && n.language === 'objc'); + const primary = new Set(); + const own = new RegExp(`@interface\\s+${name}\\b(?!\\s*\\()`); + for (const decl of decls) { + const lines = context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split(/\r?\n/) ?? []; + if (lines.slice(decl.startLine - 1, decl.endLine).some((l) => own.test(l))) primary.add(decl.filePath); + } + const inDir = (file: string) => file.startsWith(dir) && !file.includes('/', dir.length); + const files = [...new Set(decls.map((n) => n.filePath))] + .filter((file) => primary.size < 2 || inDir(file)) + .sort((a, b) => Number(b === ref.filePath) - Number(a === ref.filePath)); + memo.set(key, files); + return files; +} + +/** + * The class an Objective-C receiver is declared as when it is an instance + * variable of the class around the call, or a property read through `self.`: + * ObjFW's `[_headers mutableCopy]` on an `OFMutableDictionary *_headers;` + * ivar, the ivar a property synthesizes (`_name`, or the one `@synthesize` + * names), or `[self.weakCache objectForKey:]` on an `NSMapTable` property. A + * class it inherits from lends the ivars its `@interface` declares and its + * properties. A local or a parameter, which the shared inferrer reads first, + * shadows an ivar, and a bare name is never a property's: `arcs` in + * `initWithArcs:` is the parameter. Null when nothing declares it. + */ +function objcMemberReceiverType(receiver: string, ref: UnresolvedRef, context: ResolutionContext): string | null { + const viaSelf = receiver.startsWith('self.'); + const name = viaSelf ? receiver.slice(5) : receiver; + if (!/^[A-Za-z_]\w*$/.test(name) || name === 'self' || name === 'super') return null; + // A capitalized name is a class (`[OFString stringWithFormat:]`), not an + // ivar, and `self.name` needs a property of that name. + if (viaSelf ? !context.getNodesByName(name).some((n) => n.kind === 'property' && n.language === 'objc') : /^[A-Z]/.test(name)) { + return null; + } + const hierarchy = objcHierarchyAt(ref, context); + if (!hierarchy) return null; + // What each class of the hierarchy declares, the calling one first. + const levels = [...hierarchy].map((cls) => objcClassFiles(cls, ref, context) + .map((file) => objcFileMembers(file, context).get(cls)) + .filter((t): t is ObjcClassMembers => t !== undefined)); + // A property's type, from the calling class up; `own`: only one the calling + // class declares and may synthesize, never a category's. + const propertyType = (key: string, own: boolean): string | undefined => { + for (const level of own ? levels.slice(0, 1) : levels) { + for (const t of level) { + const p = t.properties.get(key); + if (p && !(own && p.category)) return p.type; + } + } + return undefined; + }; + if (viaSelf) return propertyType(name, false) ?? null; + for (const [i, level] of levels.entries()) { + for (const t of level) { + const type = t.ivars.get(name) ?? (i === 0 ? t.ownIvars.get(name) : undefined); + if (type) return type; + } + if (i > 0) continue; + const synthesized = level.map((t) => t.synthesized.get(name)).find((p) => p !== undefined); + const type = synthesized !== undefined ? propertyType(synthesized, false) + : name.startsWith('_') ? propertyType(name.slice(1), true) : undefined; + if (type) return type; + } + return null; +} + /** The superclasses an Objective-C class's `@interface` declarations name. */ function objcSupertypesOf(name: string, context: ResolutionContext): string[] { let memo = OBJC_SUPERS.get(context); @@ -14297,9 +14503,10 @@ function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[] 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*\\()`), + // ObjFW writes type arguments with a macro: `OFArray OF_GENERIC(OFString *) *names`. + new RegExp(`\\b([A-Z]\\w*)\\s*(?:<[^<>;]*>\\s*|[A-Z][A-Z0-9_]*\\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`), + new RegExp(`\\(\\s*(?:(?:nullable|nonnull|__kindof)\\s+)*([A-Z]\\w*)\\s*(?:<[^<>)]*>\\s*|[A-Z][A-Z0-9_]*\\s*\\([^()]*\\)\\s*)?\\*[^)]*\\)\\s*${r}\\b`), ]; case 'swift': return [ @@ -15429,6 +15636,11 @@ export function matchMethodCall( inferredType = null; } } + // An Objective-C receiver the calling method doesn't declare may be an + // instance variable or a property of its class (objcMemberReceiverType). + if (!inferredType && ref.language === 'objc' && dotMatch) { + inferredType = nmTimedT('mc-objcmember', ref, () => objcMemberReceiverType(objectOrClass!, ref, context)); + } // A pytest test's parameter is what its fixture returns: flaskbb's // `cli_runner.invoke(…)` is click's `CliRunner`, not the project's one `invoke`. if (!inferredType && ref.language === 'python' && dotMatch) inferredType = pythonFixtureReturnType(objectOrClass!, ref, context);