feat(extraction): add Objective-C language support (#165)

Adds tree-sitter-objc extractor for `.m`/`.mm` files and `.h` files
that content-sniff as Objective-C (`@interface`/`@implementation`/`@protocol`/`@synthesize`).

Extraction covers:
- `@interface` / `@implementation` (deduplicated into a single class node)
- `@protocol` (as `protocol` nodes via new `interfaceKind` config)
- Methods with full multi-part selectors (`doThing:with:`, not just `doThing`),
  including `+`/`-` static distinction
- `@property` declarations
- Inheritance (`extends`) and protocol conformance (`implements`)
- C-style `function_definition` and `#import` (both `<system>` and `"local"` forms)
- Call edges from both `call_expression` and `message_expression`,
  with `self`/`super` skipped on qualified callee names

Two new generic hooks on `LanguageExtractor` (`resolveName`,
`extractPropertyName`) handle the cases where the default name walk
doesn't fit; usable by future languages with similar shape.

Import resolver tries `.h`, `.m`, `.mm` for `objc` imports.

Validated on AFNetworking (84 files, 100% file coverage), RestKit
(282 files, 99.6%), and Texture (926 files, 100%, heavy `.mm`
content) — multi-keyword selectors preserved up to 7 parts, no parse
failures on ObjC++.

Known limitations (disclosed in README):
- Categories produce duplicate class nodes (one per category file)
- Chained/nested message sends record only the innermost method
- `[Class alloc]` patterns don't emit `instantiates` edges
- `@protocol Foo <Bar>` refinement lists not yet wired to `implements`
- Heavy C++ in `.mm` files may parse incompletely under the ObjC grammar
This commit is contained in:
0x1306a94
2026-05-26 00:31:43 -05:00
committed by GitHub
parent b48170e69f
commit 61153f96ee
10 changed files with 337 additions and 8 deletions
+108
View File
@@ -93,6 +93,14 @@ describe('Language Detection', () => {
expect(detectLanguage('main.dart')).toBe('dart');
});
it('should detect Objective-C files', () => {
expect(detectLanguage('AppDelegate.m')).toBe('objc');
expect(detectLanguage('ViewController.mm')).toBe('objc');
const objcHeader = '@interface Foo : NSObject\n@end\n';
expect(detectLanguage('Foo.h', objcHeader)).toBe('objc');
expect(detectLanguage('stdio.h', '#ifndef STDIO_H\nvoid printf();\n#endif\n')).toBe('c');
});
it('should return unknown for unsupported extensions', () => {
expect(detectLanguage('styles.css')).toBe('unknown');
expect(detectLanguage('data.json')).toBe('unknown');
@@ -3900,3 +3908,103 @@ local count = 0
});
});
});
// =============================================================================
// Objective-C
// =============================================================================
describe('Objective-C Extraction', () => {
const sample = `
#import <Foundation/Foundation.h>
#import "MyClass.h"
@interface MyClass : NSObject <NSCopying>
@property (nonatomic, copy) NSString *name;
- (void)greet;
- (void)doThing:(id)x with:(id)y;
+ (instancetype)shared;
@end
@implementation MyClass
- (void)greet {
NSLog(@"Hello");
[self doWork];
}
- (void)doThing:(id)x with:(id)y {
[self notify:x];
}
+ (instancetype)shared {
return [[MyClass alloc] init];
}
@end
void helperFunction(int count) {
MyClass *obj = [MyClass shared];
[obj greet];
}
`;
it('should extract classes, methods, functions, and imports', () => {
const result = extractFromSource('App.m', sample);
const classes = result.nodes.filter((n) => n.kind === 'class');
expect(classes.filter((c) => c.name === 'MyClass')).toHaveLength(1);
const methods = result.nodes.filter((n) => n.kind === 'method');
expect(methods.map((m) => m.name).sort()).toEqual(['doThing:with:', 'greet', 'shared']);
const shared = methods.find((m) => m.name === 'shared');
expect(shared?.isStatic).toBe(true);
const properties = result.nodes.filter((n) => n.kind === 'property');
expect(properties.some((p) => p.name === 'name')).toBe(true);
const functions = result.nodes.filter((n) => n.kind === 'function');
expect(functions.some((f) => f.name === 'helperFunction')).toBe(true);
const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
expect(imports).toContain('Foundation/Foundation.h');
expect(imports).toContain('MyClass.h');
});
it('should record inheritance and protocol conformance', () => {
const result = extractFromSource('App.m', sample);
const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends');
const implementsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'implements');
expect(extendsRefs.map((r) => r.referenceName)).toContain('NSObject');
expect(implementsRefs.map((r) => r.referenceName)).toContain('NSCopying');
});
it('should record message sends and C calls', () => {
const result = extractFromSource('App.m', sample);
const calls = result.unresolvedReferences
.filter((r) => r.referenceKind === 'calls')
.map((r) => r.referenceName);
expect(calls).toEqual(expect.arrayContaining(['NSLog', 'doWork', 'MyClass.shared', 'obj.greet']));
});
it('should not classify pure C headers with @end in comments as objc', () => {
const cHeader = '/* @end of file */\n#ifndef STDIO_H\nvoid printf(const char *);\n#endif\n';
expect(detectLanguage('stdio.h', cHeader)).toBe('c');
});
it('should extract protocol declarations', () => {
const code = `
@protocol DataSource <NSObject>
- (NSInteger)numberOfItems;
@end
`;
const result = extractFromSource('DataSource.h', code);
const protocol = result.nodes.find((n) => n.kind === 'protocol' && n.name === 'DataSource');
expect(protocol).toBeDefined();
});
it('should report Objective-C as supported', () => {
expect(isLanguageSupported('objc')).toBe(true);
expect(getSupportedLanguages()).toContain('objc');
});
});