Files
codegraph/__tests__/objc-file-scope-variables.test.ts
T
Colby MchenryandClaude Opus 5.5 ed8957710f fix(objc): a file-scope constant or global is indexed, in an @implementation too (#2699)
Objective-C read a file-scope declaration through the generic variable
fallback, which takes only a declarator that is a bare name (`int count;`).
Its most common globals were never indexed: `NSString *const
SDWebImageErrorDomain = @"…";`, `static const NSInteger kMaxRetries = 3;`,
`static dispatch_queue_t queues[4];`. A declaration written inside an
@implementation, file-scope C in the language, was not read at all,
because the class counts as an enclosing scope.

- tree-sitter.ts: Objective-C takes C's declarator branch in
  extractVariable (initialized, pointer and array declarators, with the
  initializer as the signature), keeping the bare names it always read.
  A declaration whose parent is an implementation_definition takes the
  variable branch; its globals are qualified under the class, as the
  C functions written there already are. A block or function-pointer
  variable reads as a prototype, as in C, and stays out.
- c-cpp.ts: isConstDeclaration, C's isConst, is shared, and objc.ts uses
  it, so `static const NSInteger kMaxRetries` is a constant.
- name-matcher.ts: a global that a .m or .mm file defines joins the C
  types that #2690 made file-local (OBJC_FILE_LOCAL_KINDS). A new
  isObjcFileLocalElsewhere drops such a candidate in the exact-match and
  fuzzy chains, before one is picked, and isVisibleAcrossFiles applies it
  to the pick. Before the pick, so that a header's `extern NSString
  *MJConfigFile;` stands for the definition in variables.m. Rejecting only
  the pick left Hammerspoon's MJConfigFile references unresolved. Without
  the rule, React Native's completionHandler(nil, image) block-parameter
  calls reached a completionHandler in another .mm, and #include <vector>
  a .mm's vector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:43:14 +00:00

154 lines
5.8 KiB
TypeScript

/**
* Objective-C read a file-scope variable only when its declarator was a bare
* name (`int plain;`), so its most common globals were never indexed:
*
* NSString *const SDWebImageErrorDomain = @"SDWebImageErrorDomain";
* static const NSInteger kMaxRetries = 3;
* @implementation SDImageCache
* static NSString *const kKey = @"…"; // file-scope C inside @implementation
*
* Objective-C now reads a declaration's declarators as C does (initialized,
* pointer and array ones), keeps its bare names, and reads a declaration
* written inside an `@implementation` too. A global that a `.m` file defines
* is that file's, as C makes it: another file reaches it through the header
* that declares it, and never through a namesake in another `.m`, like a
* block parameter called `completionHandler(…)`.
*/
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
describe('an Objective-C file-scope variable', () => {
beforeAll(async () => {
await initGrammars();
await loadGrammarsForLanguages(['objc']);
});
for (const crlf of [false, true]) {
const eol = (s: string) => (crlf ? s.replace(/\n/g, '\r\n') : s);
const label = crlf ? ' (CRLF)' : '';
it(`is indexed with its initializer, at file scope and inside an @implementation${label}`, () => {
const source = [
'#import <Foundation/Foundation.h>',
'',
'/// The error domain.',
'NSString *const SDWebImageErrorDomain = @"SDWebImageErrorDomain";',
'static const NSInteger kMaxRetries = 3;',
'static int counter = 0;',
'int plain;',
'static dispatch_queue_t queues[4];',
'extern NSString *const SDExternDecl;',
'static void (^gHandler)(void) = ^{ };',
'',
'@implementation SDImageCache',
'static NSString *const kKey = @"key";',
'- (void)run',
'{',
'\tint local = 1;',
'\t(void)local;',
'}',
'@end',
'',
].join('\n');
const result = extractFromSource('src/SDImageCache.m', eol(source), 'objc');
const symbols = result.nodes
.filter((n) => n.kind !== 'file' && n.kind !== 'import')
.map((n) => `${n.kind} ${n.qualifiedName}${n.signature ? ` ${n.signature}` : ''}`);
// A block or function-pointer variable reads as a prototype, as in C.
expect(symbols).toEqual([
'variable SDWebImageErrorDomain = @"SDWebImageErrorDomain"',
'constant kMaxRetries = 3',
'variable counter = 0',
'variable plain',
'variable queues',
'variable SDExternDecl',
'class SDImageCache',
'variable SDImageCache::kKey = @"key"',
'method SDImageCache::run',
]);
expect(result.nodes.find((n) => n.name === 'SDWebImageErrorDomain')?.docstring).toBe('The error domain.');
});
}
});
describe('an indexed Objective-C project with globals', () => {
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 what a method's edges, `contains` aside, reach. */
async function indexProject(files: Record<string, string>): Promise<(method: string) => string[]> {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-objc-globals-'));
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) => {
const node = graph.getNodesByName(method).find((n) => n.kind === 'method')!;
expect(node, method).toBeDefined();
return graph
.getOutgoingEdges(node.id)
.filter((e) => e.kind !== 'contains')
.map((e) => {
const t = graph.getNode(e.target)!;
return `${e.kind} ${t.kind} ${t.name} ${t.filePath}`;
});
};
}
it('reaches a global through the header that declares it', async () => {
const targetsOf = await indexProject({
'Hammerspoon/variables.h': ['#import <Foundation/Foundation.h>', '', 'extern NSString *MJConfigFile;', ''].join('\n'),
'Hammerspoon/variables.m': ['#import "variables.h"', '', 'NSString *MJConfigFile = @"~/.hammerspoon/init.lua";', ''].join('\n'),
'Hammerspoon/MJConfigUtils.m': [
'#import "variables.h"',
'',
'@implementation MJConfigUtils',
'- (NSString *)configPath',
'{',
'\treturn [MJConfigFile stringByStandardizingPath];',
'}',
'@end',
'',
].join('\n'),
});
expect(targetsOf('configPath')).toEqual(['references variable MJConfigFile Hammerspoon/variables.h']);
}, 60_000);
it('never reaches a global another .m file defines', async () => {
const targetsOf = await indexProject({
'Libraries/RCTPushNotificationManager.m': [
'#import <Foundation/Foundation.h>',
'',
'static NSString *completionHandler = nil;',
'',
].join('\n'),
'Libraries/RCTBundleAssetImageLoader.m': [
'#import <Foundation/Foundation.h>',
'',
'@implementation RCTBundleAssetImageLoader',
'- (void)loadWithCompletion:(void (^)(id image))completionHandler',
'{',
'\tcompletionHandler(nil);',
'}',
'@end',
'',
].join('\n'),
});
// The call is the block parameter's.
expect(targetsOf('loadWithCompletion:')).toEqual([]);
}, 60_000);
});