Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<WrapperProps>` followed by its CSS, or `styled(Base)<Props>`, 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 `<Text>` 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 `<Route path="/docs" element={<Docs />} />`, 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 `<Team />`, 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.
Expand Down
253 changes: 253 additions & 0 deletions __tests__/objc-typed-receivers.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): 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',
' <OFCopying>',
'{',
'\tOFDictionary *_headers;',
'}',
'@property (nonatomic, copy) OFArray *cookies;',
'@end',
'',
].join('\n')),
'src/OFHTTPServerResponse.h': eol([
'#import "OFHTTPResponse.h"',
'@interface OFHTTPServerResponse : OFHTTPResponse <OFReadyForReadingObserving,',
' OFReadyForWritingObserving>',
'/**',
' * @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<id, id> *_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);
});
Loading