From 8bf302b5b0e7063b4029bf12a220bc361cd7d265 Mon Sep 17 00:00:00 2001 From: VBS2004 Date: Wed, 8 Jul 2026 00:06:50 +0530 Subject: [PATCH] fix: add Objective-C support for .m/.mm files (#554) Add tree-sitter-objc grammar and language extractor so .m/.mm files produce meaningful structural analysis instead of empty summaries. - Add objectivec.ts language config (extensions: .m, .mm) - Add ObjcExtractor: @interface/@implementation classes, @property, @protocol, multi-part selectors, message sends, call graph - Register config + extractor in index files - Map .m/.mm -> objective-c in scan-project.mjs LANGUAGE_BY_EXT - Add tree-sitter-objc@3.0.2 dependency + allowBuild - Add 49 tests (22 unit + 8 regression + 19 registry + 2 compat) - Add 7 ObjC test fixture files covering typical iOS project patterns Fixes: #554 --- .../packages/core/package.json | 1 + .../src/__tests__/language-registry.test.ts | 6 +- .../core/src/languages/configs/index.ts | 3 + .../core/src/languages/configs/objectivec.ts | 29 ++ .../__tests__/cpp-objc-compat.scratch.test.ts | 80 +++ .../__tests__/fixtures/objc/AudioProcessor.h | 23 + .../__tests__/fixtures/objc/AudioProcessor.mm | 52 ++ .../__tests__/fixtures/objc/DataService.h | 42 ++ .../__tests__/fixtures/objc/DataService.m | 181 +++++++ .../fixtures/objc/MainViewController.h | 14 + .../fixtures/objc/MainViewController.m | 105 ++++ .../extractors/__tests__/fixtures/objc/main.m | 9 + .../__tests__/objc-extractor.test.ts | 462 ++++++++++++++++++ .../__tests__/objc-reproduction.test.ts | 194 ++++++++ .../core/src/plugins/extractors/index.ts | 3 + .../src/plugins/extractors/objc-extractor.ts | 422 ++++++++++++++++ understand-anything-plugin/pnpm-lock.yaml | 17 + .../pnpm-workspace.yaml | 1 + .../skills/understand/scan-project.mjs | 2 + 19 files changed, 1644 insertions(+), 2 deletions(-) create mode 100644 understand-anything-plugin/packages/core/src/languages/configs/objectivec.ts create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/cpp-objc-compat.scratch.test.ts create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/AudioProcessor.h create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/AudioProcessor.mm create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/DataService.h create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/DataService.m create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/MainViewController.h create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/MainViewController.m create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/main.m create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/objc-extractor.test.ts create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/objc-reproduction.test.ts create mode 100644 understand-anything-plugin/packages/core/src/plugins/extractors/objc-extractor.ts diff --git a/understand-anything-plugin/packages/core/package.json b/understand-anything-plugin/packages/core/package.json index bc3ac3be9..15285409c 100644 --- a/understand-anything-plugin/packages/core/package.json +++ b/understand-anything-plugin/packages/core/package.json @@ -47,6 +47,7 @@ "tree-sitter-go": "^0.25.0", "tree-sitter-java": "^0.23.5", "tree-sitter-javascript": "^0.25.0", + "tree-sitter-objc": "3.0.2", "tree-sitter-php": "^0.23.11", "tree-sitter-python": "^0.25.0", "tree-sitter-ruby": "^0.23.1", diff --git a/understand-anything-plugin/packages/core/src/__tests__/language-registry.test.ts b/understand-anything-plugin/packages/core/src/__tests__/language-registry.test.ts index 7eff12e45..8e60a1762 100644 --- a/understand-anything-plugin/packages/core/src/__tests__/language-registry.test.ts +++ b/understand-anything-plugin/packages/core/src/__tests__/language-registry.test.ts @@ -49,10 +49,10 @@ describe("LanguageRegistry", () => { }); describe("createDefault", () => { - it("registers all 41 built-in language configs", () => { + it("registers all 42 built-in language configs", () => { const registry = LanguageRegistry.createDefault(); const all = registry.getAllLanguages(); - expect(all.length).toBe(41); + expect(all.length).toBe(42); }); it("maps all expected extensions", () => { @@ -72,6 +72,8 @@ describe("LanguageRegistry", () => { expect(registry.getByExtension(".h")?.id).toBe("c"); expect(registry.getByExtension(".lua")?.id).toBe("lua"); expect(registry.getByExtension(".js")?.id).toBe("javascript"); + expect(registry.getByExtension(".m")?.id).toBe("objective-c"); + expect(registry.getByExtension(".mm")?.id).toBe("objective-c"); }); it("registers Swift with tree-sitter grammar metadata", () => { diff --git a/understand-anything-plugin/packages/core/src/languages/configs/index.ts b/understand-anything-plugin/packages/core/src/languages/configs/index.ts index 6a949e89d..413d551cd 100644 --- a/understand-anything-plugin/packages/core/src/languages/configs/index.ts +++ b/understand-anything-plugin/packages/core/src/languages/configs/index.ts @@ -14,6 +14,7 @@ import { cppConfig } from "./cpp.js"; import { dartConfig } from "./dart.js"; import { csharpConfig } from "./csharp.js"; import { luaConfig } from "./lua.js"; +import { objectivecConfig } from "./objectivec.js"; // Non-code language configs import { markdownConfig } from "./markdown.js"; import { yamlConfig } from "./yaml.js"; @@ -55,6 +56,7 @@ export const builtinLanguageConfigs: LanguageConfig[] = [ swiftConfig, kotlinConfig, luaConfig, + objectivecConfig, cConfig, cppConfig, dartConfig, @@ -101,6 +103,7 @@ export { swiftConfig, kotlinConfig, luaConfig, + objectivecConfig, cConfig, cppConfig, dartConfig, diff --git a/understand-anything-plugin/packages/core/src/languages/configs/objectivec.ts b/understand-anything-plugin/packages/core/src/languages/configs/objectivec.ts new file mode 100644 index 000000000..9acb33719 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/languages/configs/objectivec.ts @@ -0,0 +1,29 @@ +import type { LanguageConfig } from "../types.js"; + +export const objectivecConfig = { + id: "objective-c", + displayName: "Objective-C", + extensions: [".m", ".mm"], + treeSitter: { + wasmPackage: "tree-sitter-objc", + wasmFile: "tree-sitter-objc.wasm", + }, + concepts: [ + "message sending", + "protocols", + "categories", + "class extensions", + "property attributes", + "ARC memory management", + "blocks", + "dynamic dispatch", + "key-value observing", + "Objective-C runtime", + ], + filePatterns: { + entryPoints: ["main.m", "AppDelegate.m"], + barrels: [], + tests: ["*Tests.m", "*Spec.m", "Tests/**/*.m"], + config: ["Podfile", "*.xcodeproj", "*.xcworkspace"], + }, +} satisfies LanguageConfig; diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/cpp-objc-compat.scratch.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/cpp-objc-compat.scratch.test.ts new file mode 100644 index 000000000..815b3dfc5 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/cpp-objc-compat.scratch.test.ts @@ -0,0 +1,80 @@ +/** + * Scratch test: Can tree-sitter-cpp grammar parse Objective-C syntax? + * + * This answers the question: "Can we just reuse CppExtractor for ObjC?" + */ +import { describe, it, expect, beforeAll } from "vitest"; +import { createRequire } from "node:module"; +import { CppExtractor } from "../cpp-extractor.js"; + +const require = createRequire(import.meta.url); + +let Parser: any; +let Language: any; +let cppLang: any; + +beforeAll(async () => { + const mod = await import("web-tree-sitter"); + Parser = mod.Parser; + Language = mod.Language; + await Parser.init(); + const wasmPath = require.resolve("tree-sitter-cpp/tree-sitter-cpp.wasm"); + cppLang = await Language.load(wasmPath); +}); + +function parse(code: string) { + const parser = new Parser(); + parser.setLanguage(cppLang); + const tree = parser.parse(code); + return { tree, parser, root: tree.rootNode }; +} + +const OBJC_CODE = ` +#import +#import "DataService.h" + +@interface SomeManager : NSObject +@property (nonatomic, strong) NSString *name; +- (void)loadData; ++ (SomeManager *)sharedInstance; +@end + +@implementation SomeManager ++ (SomeManager *)sharedInstance { + static SomeManager *instance = nil; + return instance; +} +- (void)loadData { + NSLog(@"loading"); +} +@end +`; + +describe("Can tree-sitter-cpp parse Objective-C?", () => { + const extractor = new CppExtractor(); + + it("tree-sitter-cpp produces ERROR nodes when parsing @interface/@implementation", () => { + const { tree, parser, root } = parse(OBJC_CODE); + const rootStr = root.toString(); + console.log("AST root:", rootStr.slice(0, 500)); + // ObjC syntax (@interface, @implementation, message sends) is NOT valid C++ + // The parser will produce ERROR nodes, confirming it can't handle ObjC + const hasErrors = rootStr.includes("ERROR"); + expect(hasErrors).toBe(true); // C++ grammar chokes on ObjC syntax + tree.delete(); + parser.delete(); + }); + + it("CppExtractor.extractStructure produces empty results for ObjC code (even with cpp grammar)", () => { + const { tree, parser, root } = parse(OBJC_CODE); + const result = extractor.extractStructure(root); + console.log("Functions found:", result.functions.map(f => f.name)); + console.log("Classes found:", result.classes.map(c => c.name)); + // The CppExtractor won't find @interface/@implementation as classes, + // and won't find - (void)method: signatures as functions + expect(result.classes).toHaveLength(0); // confirms we CANNOT reuse CppExtractor + expect(result.functions).toHaveLength(0); + tree.delete(); + parser.delete(); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/AudioProcessor.h b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/AudioProcessor.h new file mode 100644 index 000000000..3588ff8ed --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/AudioProcessor.h @@ -0,0 +1,23 @@ +#import +#import +#import + +/// Hybrid audio processor — mixes Objective-C with C++ for audio DSP. +/// Handles audio buffer processing with SIMD optimizations via Accelerate +/// framework and raw C++ vector operations. +@interface AudioProcessor : NSObject { +@public + std::vector _sampleBuffer; +} + +@property (nonatomic, assign) float gain; +@property (nonatomic, assign, readonly) NSUInteger sampleCount; +@property (nonatomic, strong) AVAudioEngine *audioEngine; + ++ (instancetype)processorWithSampleRate:(double)sampleRate; + +- (void)processBuffer:(AVAudioPCMBuffer *)buffer; +- (std::vector)extractSamplesFromBuffer:(AVAudioPCMBuffer *)buffer; +- (void)applyGainToSamples:(std::vector &)samples; + +@end diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/AudioProcessor.mm b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/AudioProcessor.mm new file mode 100644 index 000000000..f0159d843 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/AudioProcessor.mm @@ -0,0 +1,52 @@ +#import "AudioProcessor.h" +#import +#import + +@implementation AudioProcessor + ++ (instancetype)processorWithSampleRate:(double)sampleRate { + AudioProcessor *processor = [[AudioProcessor alloc] init]; + processor.audioEngine = [[AVAudioEngine alloc] init]; + processor.gain = 1.0f; + return processor; +} + +- (std::vector)extractSamplesFromBuffer:(AVAudioPCMBuffer *)buffer { + std::vector samples; + AVAudioFrameCount frameCount = buffer.frameLength; + + if (frameCount == 0) return samples; + + samples.resize(frameCount); + float *channelData = buffer.floatChannelData[0]; + memcpy(samples.data(), channelData, frameCount * sizeof(float)); + + return samples; +} + +- (void)applyGainToSamples:(std::vector &)samples { + float scalar = self.gain; + vDSP_vsmul(samples.data(), 1, &scalar, samples.data(), 1, samples.size()); +} + +- (void)processBuffer:(AVAudioPCMBuffer *)buffer { + auto samples = [self extractSamplesFromBuffer:buffer]; + [self applyGainToSamples:samples]; + + // C++ algorithm: clamp to [-1.0, 1.0] + std::transform(samples.begin(), samples.end(), samples.begin(), [](float s) { + return std::max(-1.0f, std::min(1.0f, s)); + }); + + NSUInteger count = samples.size(); + _sampleBuffer = std::move(samples); + _sampleCount = count; + + NSLog(@"Processed %lu samples with gain %.2f", (unsigned long)count, self.gain); +} + +- (NSUInteger)sampleCount { + return _sampleBuffer.size(); +} + +@end diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/DataService.h b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/DataService.h new file mode 100644 index 000000000..95b3b932d --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/DataService.h @@ -0,0 +1,42 @@ +#import + +/// Core data service — handles network requests, caching, and data pipelines +/// for the entire application. This is the primary entry point for all data +/// operations. +@protocol DataServiceDelegate; + +@interface DataService : NSObject + +@property (nonatomic, strong, readonly) NSURLSession *session; +@property (nonatomic, assign) BOOL isOnline; +@property (nonatomic, weak) id delegate; + +// Lifecycle +- (instancetype)initWithConfiguration:(NSDictionary *)config; ++ (instancetype)sharedService; + +// Data fetching +- (void)fetchItemsWithCompletion:(void (^)(NSArray *items, NSError *error))completion; +- (void)fetchItemWithID:(NSString *)itemID completion:(void (^)(id item, NSError *error))completion; + +// Multi-part selector +- (NSArray *)filterItems:(NSArray *)rawItems + withCriteria:(NSDictionary *)criteria + sortedBy:(NSString *)sortKey; + +// Batch operations +- (void)batchUpdateItems:(NSArray *)items + withHandler:(void (^)(BOOL success, NSInteger updatedCount))handler; + +@end + +@protocol DataServiceDelegate + +@required +- (void)dataServiceDidConnect:(DataService *)service; +- (void)dataService:(DataService *)service didFailWithError:(NSError *)error; + +@optional +- (void)dataServiceDidGoOffline:(DataService *)service; + +@end diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/DataService.m b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/DataService.m new file mode 100644 index 000000000..c47f0416c --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/DataService.m @@ -0,0 +1,181 @@ +#import "DataService.h" +#import "CacheManager.h" +#import "NetworkClient.h" + +@interface DataService () + +@property (nonatomic, strong) CacheManager *cacheManager; +@property (nonatomic, strong) NetworkClient *networkClient; +@property (nonatomic, strong) dispatch_queue_t workQueue; + +@end + +@implementation DataService + ++ (instancetype)sharedService { + static DataService *instance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[DataService alloc] initWithConfiguration:nil]; + }); + return instance; +} + +- (instancetype)initWithConfiguration:(NSDictionary *)config { + self = [super init]; + if (self) { + _workQueue = dispatch_queue_create("com.example.dataservice", DISPATCH_QUEUE_CONCURRENT); + _networkClient = [[NetworkClient alloc] init]; + _cacheManager = [[CacheManager alloc] init]; + _isOnline = YES; + + [self setupNotifications]; + } + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +#pragma mark - Setup + +- (void)setupNotifications { + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(reachabilityChanged:) + name:kReachabilityChangedNotification + object:nil]; +} + +- (void)reachabilityChanged:(NSNotification *)notification { + BOOL reachable = [notification.userInfo[@"reachable"] boolValue]; + self.isOnline = reachable; + if (!reachable) { + [self handleOfflineTransition]; + } else { + [self.delegate dataServiceDidConnect:self]; + } +} + +#pragma mark - Data Fetching + +- (void)fetchItemsWithCompletion:(void (^)(NSArray *items, NSError *error))completion { + if (!self.isOnline) { + NSArray *cached = [self.cacheManager cachedItems]; + if (cached) { + completion(cached, nil); + return; + } + } + + [self.networkClient GET:@"/api/items" parameters:nil completion:^(id response, NSError *error) { + if (error) { + [self.delegate dataService:self didFailWithError:error]; + completion(nil, error); + return; + } + + NSArray *items = response[@"data"]; + [self.cacheManager cacheItems:items]; + + dispatch_async(dispatch_get_main_queue(), ^{ + completion(items, nil); + }); + }]; +} + +- (void)fetchItemWithID:(NSString *)itemID completion:(void (^)(id item, NSError *error))completion { + NSString *cacheKey = [NSString stringWithFormat:@"item_%@", itemID]; + id cached = [self.cacheManager objectForKey:cacheKey]; + if (cached) { + completion(cached, nil); + return; + } + + NSString *endpoint = [NSString stringWithFormat:@"/api/items/%@", itemID]; + [self.networkClient GET:endpoint parameters:nil completion:^(id response, NSError *error) { + if (error) { + completion(nil, error); + return; + } + id item = response[@"data"]; + [self.cacheManager setObject:item forKey:cacheKey]; + completion(item, nil); + }]; +} + +#pragma mark - Filtering & Processing + +- (NSArray *)filterItems:(NSArray *)rawItems + withCriteria:(NSDictionary *)criteria + sortedBy:(NSString *)sortKey { + NSPredicate *predicate = [self predicateFromCriteria:criteria]; + NSArray *filtered = [rawItems filteredArrayUsingPredicate:predicate]; + + if (sortKey) { + NSSortDescriptor *descriptor = [NSSortDescriptor sortDescriptorWithKey:sortKey ascending:YES]; + filtered = [filtered sortedArrayUsingDescriptors:@[descriptor]]; + } + + return filtered; +} + +- (NSPredicate *)predicateFromCriteria:(NSDictionary *)criteria { + NSMutableArray *subpredicates = [NSMutableArray array]; + [criteria enumerateKeysAndObjectsUsingBlock:^(NSString *key, id value, BOOL *stop) { + NSPredicate *sub = [NSPredicate predicateWithFormat:@"%K == %@", key, value]; + [subpredicates addObject:sub]; + }]; + return [NSCompoundPredicate andPredicateWithSubpredicates:subpredicates]; +} + +#pragma mark - Batch Operations + +- (void)batchUpdateItems:(NSArray *)items + withHandler:(void (^)(BOOL success, NSInteger updatedCount))handler { + dispatch_barrier_async(self.workQueue, ^{ + NSInteger count = 0; + BOOL allSuccess = YES; + + for (NSDictionary *item in items) { + NSString *itemID = item[@"id"]; + BOOL ok = [self updateSingleItem:itemID withData:item]; + if (ok) { + count++; + } else { + allSuccess = NO; + } + } + + dispatch_async(dispatch_get_main_queue(), ^{ + handler(allSuccess, count); + }); + + if (count > 0) { + [self.delegate dataServiceDidConnect:self]; // notify after batch + } + }); +} + +- (BOOL)updateSingleItem:(NSString *)itemID withData:(NSDictionary *)data { + [self.cacheManager invalidateKey:[NSString stringWithFormat:@"item_%@", itemID]]; + return YES; +} + +#pragma mark - Offline Handling + +- (void)handleOfflineTransition { + [self.cacheManager persistToDisk]; + if ([self.delegate respondsToSelector:@selector(dataServiceDidGoOffline:)]) { + [self.delegate dataServiceDidGoOffline:self]; + } +} + +#pragma mark - Singleton Reset (Testing) + +- (void)resetService { + [self.cacheManager clearAll]; + self.isOnline = YES; +} + +@end diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/MainViewController.h b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/MainViewController.h new file mode 100644 index 000000000..a5e4e5485 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/MainViewController.h @@ -0,0 +1,14 @@ +#import + +/// Root view controller — manages the main table view and coordinates between +/// the data service and the UI layer. This is the first screen users see. +@interface MainViewController : UIViewController + +@property (nonatomic, strong) UITableView *tableView; +@property (nonatomic, strong) UIRefreshControl *refreshControl; +@property (nonatomic, strong) NSArray *displayItems; + +- (instancetype)initWithDataService:(DataService *)service; +- (void)refreshData; + +@end diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/MainViewController.m b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/MainViewController.m new file mode 100644 index 000000000..bb0ea1138 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/MainViewController.m @@ -0,0 +1,105 @@ +#import "MainViewController.h" +#import "DataService.h" +#import "ItemCell.h" + +@implementation MainViewController { + DataService *_dataService; +} + +- (instancetype)initWithDataService:(DataService *)service { + self = [super initWithNibName:nil bundle:nil]; + if (self) { + _dataService = service; + self.title = @"Items"; + self.displayItems = @[]; + } + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + + [self setupTableView]; + [self refreshData]; +} + +- (void)setupTableView { + self.tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStylePlain]; + self.tableView.dataSource = self; + self.tableView.delegate = self; + self.tableView.rowHeight = UITableViewAutomaticDimension; + self.tableView.estimatedRowHeight = 80; + [self.tableView registerClass:[ItemCell class] forCellReuseIdentifier:@"ItemCell"]; + [self.view addSubview:self.tableView]; + + self.refreshControl = [[UIRefreshControl alloc] init]; + [self.refreshControl addTarget:self + action:@selector(refreshData) + forControlEvents:UIControlEventValueChanged]; + self.tableView.refreshControl = self.refreshControl; +} + +#pragma mark - Data + +- (void)refreshData { + [self.refreshControl beginRefreshing]; + [_dataService fetchItemsWithCompletion:^(NSArray *items, NSError *error) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.refreshControl endRefreshing]; + if (error) { + [self showError:error]; + return; + } + self.displayItems = items; + [self.tableView reloadData]; + }); + }]; +} + +- (void)showError:(NSError *)error { + UIAlertController *alert = [UIAlertController + alertControllerWithTitle:@"Error" + message:error.localizedDescription + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]]; + [self presentViewController:alert animated:YES completion:nil]; +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + return self.displayItems.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + ItemCell *cell = [tableView dequeueReusableCellWithIdentifier:@"ItemCell" forIndexPath:indexPath]; + NSDictionary *item = self.displayItems[indexPath.row]; + [cell configureWithItem:item]; + return cell; +} + +#pragma mark - UITableViewDelegate + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + + NSDictionary *item = self.displayItems[indexPath.row]; + NSString *itemID = item[@"id"]; + + [_dataService fetchItemWithID:itemID completion:^(id detail, NSError *error) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (error) { + [self showError:error]; + return; + } + // Navigate to detail view + [self showDetailForItem:detail]; + }); + }]; +} + +- (void)showDetailForItem:(id)item { + NSLog(@"Showing detail for: %@", item); +} + +@end diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/main.m b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/main.m new file mode 100644 index 000000000..82f1b348f --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/fixtures/objc/main.m @@ -0,0 +1,9 @@ +#import +#import "AppDelegate.h" + +/// Application entry point. +int main(int argc, char *argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); + } +} diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/objc-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/objc-extractor.test.ts new file mode 100644 index 000000000..ddc1f0778 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/objc-extractor.test.ts @@ -0,0 +1,462 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { createRequire } from "node:module"; +import { ObjcExtractor } from "../objc-extractor.js"; + +const require = createRequire(import.meta.url); + +let Parser: any; +let Language: any; +let objcLang: any; + +beforeAll(async () => { + const mod = await import("web-tree-sitter"); + Parser = mod.Parser; + Language = mod.Language; + await Parser.init(); + const wasmPath = require.resolve( + "tree-sitter-objc/tree-sitter-objc.wasm", + ); + objcLang = await Language.load(wasmPath); +}); + +function parse(code: string) { + const parser = new Parser(); + parser.setLanguage(objcLang); + const tree = parser.parse(code); + return { tree, parser, root: tree.rootNode }; +} + +function withAnalysis( + code: string, + fn: (result: ReturnType) => T, +): T { + const { tree, parser, root } = parse(code); + try { + return fn(extractor.extractStructure(root)); + } finally { + tree.delete(); + parser.delete(); + } +} + +function withCalls( + code: string, + fn: (result: ReturnType) => T, +): T { + const { tree, parser, root } = parse(code); + try { + return fn(extractor.extractCallGraph(root)); + } finally { + tree.delete(); + parser.delete(); + } +} + +const extractor = new ObjcExtractor(); + +describe("ObjcExtractor", () => { + it("has correct languageIds", () => { + expect(extractor.languageIds).toEqual(["objective-c"]); + }); + + // --------------------------------------------------------------------------- + // Imports + // --------------------------------------------------------------------------- + + describe("extractStructure - imports", () => { + it("extracts system framework imports (#import <...>)", () => { + withAnalysis( + ` +#import +#import +`, + (result) => { + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe(""); + expect(result.imports[0].lineNumber).toBeGreaterThan(0); + expect(result.imports[1].source).toBe(""); + }, + ); + }); + + it("extracts local header imports (#import \"...\")", () => { + withAnalysis( + ` +#import "DataService.h" +#import "UserModel.h" +`, + (result) => { + expect(result.imports).toHaveLength(2); + expect(result.imports[0].source).toBe("DataService.h"); + expect(result.imports[1].source).toBe("UserModel.h"); + }, + ); + }); + }); + + // --------------------------------------------------------------------------- + // @interface — class declarations + // --------------------------------------------------------------------------- + + describe("extractStructure - @interface (class declarations)", () => { + it("extracts class name from @interface", () => { + withAnalysis( + ` +@interface SomeManager : NSObject +@end +`, + (result) => { + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("SomeManager"); + expect(result.classes[0].lineRange[0]).toBeGreaterThan(0); + }, + ); + }); + + it("extracts @property names from @interface", () => { + withAnalysis( + ` +@interface SomeManager : NSObject +@property (nonatomic, strong) NSString *name; +@property (nonatomic, assign) BOOL isLoaded; +@end +`, + (result) => { + expect(result.classes).toHaveLength(1); + const cls = result.classes[0]; + expect(cls.properties).toContain("name"); + expect(cls.properties).toContain("isLoaded"); + }, + ); + }); + + it("extracts instance method declarations from @interface", () => { + withAnalysis( + ` +@interface SomeManager : NSObject +- (void)loadData; +- (NSArray *)processItems:(NSArray *)rawItems; +@end +`, + (result) => { + expect(result.classes).toHaveLength(1); + const cls = result.classes[0]; + expect(cls.methods).toContain("loadData"); + expect(cls.methods).toContain("processItems:"); + }, + ); + }); + + it("extracts class method declarations from @interface", () => { + withAnalysis( + ` +@interface SomeManager : NSObject ++ (SomeManager *)sharedInstance; ++ (instancetype)new; +@end +`, + (result) => { + const cls = result.classes[0]; + expect(cls.methods).toContain("sharedInstance"); + expect(cls.methods).toContain("new"); + }, + ); + }); + + it("extracts multi-part selector method names", () => { + withAnalysis( + ` +@interface SomeManager : NSObject +- (NSArray *)processItems:(NSArray *)rawItems withFilter:(NSString *)filter; +@end +`, + (result) => { + const cls = result.classes[0]; + expect(cls.methods).toContain("processItems:withFilter:"); + }, + ); + }); + }); + + // --------------------------------------------------------------------------- + // @implementation — class implementations + // --------------------------------------------------------------------------- + + describe("extractStructure - @implementation", () => { + it("extracts methods from @implementation", () => { + withAnalysis( + ` +@implementation SomeManager +- (void)loadData { + NSLog(@"loading"); +} ++ (SomeManager *)sharedInstance { + static SomeManager *instance = nil; + return instance; +} +@end +`, + (result) => { + expect(result.classes).toHaveLength(1); + const cls = result.classes[0]; + expect(cls.name).toBe("SomeManager"); + expect(cls.methods).toContain("loadData"); + expect(cls.methods).toContain("sharedInstance"); + }, + ); + }); + + it("surfaces methods in functions[] array", () => { + withAnalysis( + ` +@implementation SomeManager +- (void)loadData { + NSLog(@"loading"); +} +- (NSArray *)processItems:(NSArray *)rawItems withFilter:(NSString *)filter { + return rawItems; +} +@end +`, + (result) => { + const funcNames = result.functions.map((f) => f.name); + expect(funcNames).toContain("loadData"); + expect(funcNames).toContain("processItems:withFilter:"); + }, + ); + }); + + it("merges @interface and @implementation into one class entry", () => { + withAnalysis( + ` +@interface SomeManager : NSObject +@property (nonatomic, strong) NSString *name; +- (void)loadData; +@end + +@implementation SomeManager +- (void)loadData { + NSLog(@"loading"); +} +@end +`, + (result) => { + // Both @interface and @implementation refer to the same class — + // they should be merged into ONE class entry. + expect(result.classes).toHaveLength(1); + const cls = result.classes[0]; + expect(cls.name).toBe("SomeManager"); + expect(cls.properties).toContain("name"); + expect(cls.methods).toContain("loadData"); + }, + ); + }); + + it("extracts method params from @implementation", () => { + withAnalysis( + ` +@implementation SomeManager +- (NSArray *)processItems:(NSArray *)rawItems withFilter:(NSString *)filter { + return rawItems; +} +@end +`, + (result) => { + const func = result.functions.find( + (f) => f.name === "processItems:withFilter:", + ); + expect(func).toBeDefined(); + expect(func!.params).toEqual(["rawItems", "filter"]); + }, + ); + }); + + it("extracts return type from @implementation methods", () => { + withAnalysis( + ` +@implementation SomeManager +- (NSArray *)processItems:(NSArray *)rawItems { + return rawItems; +} +@end +`, + (result) => { + const func = result.functions.find( + (f) => f.name === "processItems:", + ); + expect(func).toBeDefined(); + expect(func!.returnType).toContain("NSArray"); + }, + ); + }); + }); + + // --------------------------------------------------------------------------- + // Exports + // --------------------------------------------------------------------------- + + describe("extractStructure - exports", () => { + it("surfaces classes as exports (ObjC public header convention)", () => { + withAnalysis( + ` +@interface Foo : NSObject +@end +@interface Bar : NSObject +@end +`, + (result) => { + const names = result.exports.map((e) => e.name); + expect(names).toContain("Foo"); + expect(names).toContain("Bar"); + }, + ); + }); + }); + + // --------------------------------------------------------------------------- + // @protocol + // --------------------------------------------------------------------------- + + describe("extractStructure - @protocol", () => { + it("extracts protocol as a class entry with methods", () => { + withAnalysis( + ` +@protocol DataDelegate +- (void)didLoadData:(NSArray *)data; +- (void)didFailWithError:(NSError *)error; +@end +`, + (result) => { + expect(result.classes).toHaveLength(1); + const proto = result.classes[0]; + expect(proto.name).toBe("DataDelegate"); + expect(proto.methods).toContain("didLoadData:"); + expect(proto.methods).toContain("didFailWithError:"); + }, + ); + }); + }); + + // --------------------------------------------------------------------------- + // Full combined file + // --------------------------------------------------------------------------- + + describe("extractStructure - comprehensive file", () => { + const code = ` +#import +#import "DataService.h" + +@interface SomeManager : NSObject +@property (nonatomic, strong) DataService *dataService; +@property (nonatomic, assign) BOOL isLoaded; +- (instancetype)initWithService:(DataService *)service; +- (void)loadDataWithCompletion:(void (^)(NSArray *items, NSError *error))completion; +- (NSArray *)processItems:(NSArray *)rawItems; ++ (SomeManager *)sharedInstance; +@end + +@implementation SomeManager ++ (SomeManager *)sharedInstance { + static SomeManager *instance = nil; + return instance; +} +- (instancetype)initWithService:(DataService *)service { + self = [super init]; + if (self) { + _dataService = service; + _isLoaded = NO; + } + return self; +} +- (void)loadDataWithCompletion:(void (^)(NSArray *items, NSError *error))completion { + [self.dataService fetchItemsWithCompletion:^(NSArray *raw, NSError *err) { + completion(nil, err); + }]; +} +- (NSArray *)processItems:(NSArray *)rawItems { + NSMutableArray *result = [NSMutableArray array]; + return [result copy]; +} +@end +`; + + it("extracts imports", () => + withAnalysis(code, (result) => { + expect(result.imports.length).toBeGreaterThanOrEqual(2); + })); + + it("extracts exactly one class (merged @interface + @implementation)", () => + withAnalysis(code, (result) => { + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("SomeManager"); + })); + + it("extracts all properties", () => + withAnalysis(code, (result) => { + const props = result.classes[0].properties; + expect(props).toContain("dataService"); + expect(props).toContain("isLoaded"); + })); + + it("extracts all methods", () => + withAnalysis(code, (result) => { + const methods = result.classes[0].methods; + expect(methods).toContain("sharedInstance"); + expect(methods).toContain("initWithService:"); + expect(methods).toContain("loadDataWithCompletion:"); + expect(methods).toContain("processItems:"); + })); + + it("functions array has non-zero length", () => + withAnalysis(code, (result) => { + expect(result.functions.length).toBeGreaterThan(0); + })); + }); + + // --------------------------------------------------------------------------- + // Call graph + // --------------------------------------------------------------------------- + + describe("extractCallGraph", () => { + it("extracts message sends as caller→callee edges", () => { + withCalls( + ` +@implementation SomeManager +- (void)loadData { + [self processItems:nil]; + NSLog(@"done"); +} +- (NSArray *)processItems:(NSArray *)raw { + return raw; +} +@end +`, + (calls) => { + expect(calls.length).toBeGreaterThan(0); + const msgCall = calls.find( + (c) => c.caller === "loadData" && c.callee === "processItems", + ); + expect(msgCall).toBeDefined(); + }, + ); + }); + + it("extracts C-style function calls (NSLog, dispatch_once, etc.)", () => { + withCalls( + ` +@implementation SomeManager +- (void)loadData { + NSLog(@"loading"); +} +@end +`, + (calls) => { + const logCall = calls.find( + (c) => c.caller === "loadData" && c.callee === "NSLog", + ); + expect(logCall).toBeDefined(); + }, + ); + }); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/objc-reproduction.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/objc-reproduction.test.ts new file mode 100644 index 000000000..15ba25335 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/objc-reproduction.test.ts @@ -0,0 +1,194 @@ +/** + * Reproduction test for GitHub Issue #554: + * "bug: No Objective-C support" + * + * This test file documents the FAILING state of the codebase before the fix. + * All tests here are expected to FAIL on the current main branch, confirming + * the bug is reproducible. They will PASS once the fix is implemented. + * + * @see https://github.com/Egonex-AI/Understand-Anything/issues/554 + */ + +import { describe, it, expect, beforeAll } from "vitest"; +import { LanguageRegistry } from "../../../languages/language-registry.js"; +import { TreeSitterPlugin } from "../../tree-sitter-plugin.js"; + +// --------------------------------------------------------------------------- +// Fixtures — representative Objective-C / Objective-C++ source snippets +// --------------------------------------------------------------------------- + +const OBJC_MANAGER = ` +#import +#import "DataService.h" +#import "UserModel.h" + +@interface SomeManager : NSObject + +@property (nonatomic, strong) DataService *dataService; +@property (nonatomic, assign) BOOL isLoaded; + +- (instancetype)initWithService:(DataService *)service; +- (void)loadDataWithCompletion:(void (^)(NSArray *items, NSError *error))completion; +- (NSArray *)processItems:(NSArray *)rawItems; ++ (SomeManager *)sharedInstance; + +@end + +@implementation SomeManager + ++ (SomeManager *)sharedInstance { + static SomeManager *instance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[SomeManager alloc] init]; + }); + return instance; +} + +- (instancetype)initWithService:(DataService *)service { + self = [super init]; + if (self) { + _dataService = service; + _isLoaded = NO; + } + return self; +} + +- (void)loadDataWithCompletion:(void (^)(NSArray *items, NSError *error))completion { + [self.dataService fetchItemsWithCompletion:^(NSArray *raw, NSError *err) { + if (err) { + completion(nil, err); + return; + } + NSArray *processed = [self processItems:raw]; + self.isLoaded = YES; + completion(processed, nil); + }]; +} + +- (NSArray *)processItems:(NSArray *)rawItems { + NSMutableArray *result = [NSMutableArray array]; + for (id item in rawItems) { + [result addObject:[UserModel modelWithRaw:item]]; + } + return [result copy]; +} + +@end +`; + +const OBJCPP_WRAPPER = ` +#import +#include +#include + +@interface CppBridge : NSObject + +- (instancetype)init; +- (NSArray *)processData:(NSData *)data; + +@end + +@implementation CppBridge { + std::vector _cache; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _cache.reserve(64); + } + return self; +} + +- (NSArray *)processData:(NSData *)data { + const char *bytes = (const char *)[data bytes]; + _cache.push_back(std::string(bytes)); + return @[@(bytes)]; +} + +@end +`; + +// --------------------------------------------------------------------------- +// 1. Language Registry — must NOT recognise .m/.mm yet (bug confirmation) +// --------------------------------------------------------------------------- + +describe("Issue #554 reproduction — LanguageRegistry", () => { + let registry: LanguageRegistry; + + beforeAll(() => { + registry = LanguageRegistry.createDefault(); + }); + + it("FIXED: getForFile returns objectivec config for .m files", () => { + const config = registry.getForFile("SomeManager.m"); + expect(config).not.toBeNull(); + expect(config!.id).toBe("objective-c"); + }); + + it("FIXED: getForFile returns objectivec config for .mm files", () => { + const config = registry.getForFile("CppBridge.mm"); + expect(config).not.toBeNull(); + expect(config!.id).toBe("objective-c"); + }); + + it("FIXED: getById returns the config for 'objective-c'", () => { + const config = registry.getById("objective-c"); + expect(config).not.toBeNull(); + expect(config!.displayName).toBe("Objective-C"); + }); + + it("reference: getForFile correctly returns a config for .swift (sanity check)", () => { + const config = registry.getForFile("AppDelegate.swift"); + expect(config).not.toBeNull(); + expect(config!.id).toBe("swift"); + }); +}); + +// --------------------------------------------------------------------------- +// 2. TreeSitterPlugin — analyzeFile must return empty results for .m/.mm +// --------------------------------------------------------------------------- + +describe("Issue #554 reproduction — TreeSitterPlugin structural analysis", () => { + let plugin: TreeSitterPlugin; + + beforeAll(async () => { + // Initialize plugin with all builtin configs — including objectivecConfig — + // which is how the plugin is used in production via plugin-discovery. + const registry = LanguageRegistry.createDefault(); + plugin = new TreeSitterPlugin(registry.getAllLanguages()); + await plugin.init(); + }); + + it("FIXED: analyzeFile returns functions and classes for a .m file", () => { + const result = plugin.analyzeFile("SomeManager.m", OBJC_MANAGER); + expect(result.functions.length).toBeGreaterThan(0); + expect(result.classes.length).toBeGreaterThan(0); + expect(result.imports.length).toBeGreaterThan(0); + }); + + it("FIXED: analyzeFile returns functions and classes for a .mm file", () => { + const result = plugin.analyzeFile("CppBridge.mm", OBJCPP_WRAPPER); + expect(result.functions.length).toBeGreaterThan(0); + expect(result.classes.length).toBeGreaterThan(0); + }); + + it("FIXED: 'objective-c' is now in the list of supported languages", () => { + expect(plugin.languages).toContain("objective-c"); + }); + + it("reference: analyzeFile correctly extracts functions from .ts (sanity check)", () => { + const tsCode = ` +function greet(name: string): string { + return "Hello " + name; +} +class Foo { + bar(): void {} +} +`; + const result = plugin.analyzeFile("test.ts", tsCode); + expect(result.functions.length).toBeGreaterThan(0); + expect(result.classes.length).toBeGreaterThan(0); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts index d4327d7fa..fc6401959 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts @@ -12,6 +12,7 @@ export { CSharpExtractor } from "./csharp-extractor.js"; export { DartExtractor } from "./dart-extractor.js"; export { KotlinExtractor } from "./kotlin-extractor.js"; export { SwiftExtractor } from "./swift-extractor.js"; +export { ObjcExtractor } from "./objc-extractor.js"; import type { LanguageExtractor } from "./types.js"; import { TypeScriptExtractor } from "./typescript-extractor.js"; @@ -26,6 +27,7 @@ import { CSharpExtractor } from "./csharp-extractor.js"; import { DartExtractor } from "./dart-extractor.js"; import { KotlinExtractor } from "./kotlin-extractor.js"; import { SwiftExtractor } from "./swift-extractor.js"; +import { ObjcExtractor } from "./objc-extractor.js"; export const builtinExtractors: LanguageExtractor[] = [ new TypeScriptExtractor(), @@ -40,4 +42,5 @@ export const builtinExtractors: LanguageExtractor[] = [ new DartExtractor(), new KotlinExtractor(), new SwiftExtractor(), + new ObjcExtractor(), ]; diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/objc-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/objc-extractor.ts new file mode 100644 index 000000000..774714825 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/objc-extractor.ts @@ -0,0 +1,422 @@ +import type { StructuralAnalysis, CallGraphEntry } from "../../types.js"; +import type { LanguageExtractor, TreeSitterNode } from "./types.js"; +import { findChild, findChildren } from "./base-extractor.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function lineRange(node: TreeSitterNode): [number, number] { + return [node.startPosition.row + 1, node.endPosition.row + 1]; +} + +/** + * Build a human-readable method selector name from a method_declaration or + * method_definition node. + * + * The ObjC grammar lays out a method node like: + * (method_type) identifier("keyword1") (method_parameter) identifier("keyword2") (method_parameter) … + * + * Algorithm: collect identifier children in order. Between each consecutive + * pair of identifiers there is a method_parameter, so every identifier except + * the last (which follows a preceding method_parameter) gets a ":" appended. + * + * Single-part selectors (no parameters) have exactly one identifier → no ":". + */ +function extractMethodName(node: TreeSitterNode): string { + // Collect named children in order, skipping method_type + const identifiers: string[] = []; + const hasParams: boolean[] = []; + + let seenMethodType = false; + let prevWasParam = false; + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child) continue; + + if (child.type === "method_type") { + seenMethodType = true; + continue; + } + + if (!seenMethodType) continue; + + if (child.type === "identifier") { + identifiers.push(child.text); + hasParams.push(false); // will be updated when we see the following method_parameter + prevWasParam = false; + } else if (child.type === "method_parameter") { + // The identifier immediately preceding this param keyword takes a ":" + if (identifiers.length > 0) { + hasParams[identifiers.length - 1] = true; + } + prevWasParam = true; + } + } + + if (identifiers.length === 0) return ""; + + return identifiers + .map((id, i) => (hasParams[i] ? id + ":" : id)) + .join(""); +} + +/** Find the first keyword identifier of a method node (the base name, for call graph). */ +function extractMethodBaseName(node: TreeSitterNode): string { + let seenMethodType = false; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child) continue; + if (child.type === "method_type") { seenMethodType = true; continue; } + if (seenMethodType && child.type === "identifier") return child.text; + } + return ""; +} + +/** + * Extract parameter local names from a method_declaration or method_definition. + * Each method_parameter contains a method_type and an identifier (the local name). + */ +function extractMethodParams(node: TreeSitterNode): string[] { + const params: string[] = []; + const paramNodes = findChildren(node, "method_parameter"); + for (const param of paramNodes) { + const id = findChild(param, "identifier"); + if (id) params.push(id.text); + } + return params; +} + +/** Extract the return type text from the method_type node. */ +function extractReturnType(node: TreeSitterNode): string | undefined { + const methodType = findChild(node, "method_type"); + if (!methodType) return undefined; + const typeName = findChild(methodType, "type_name"); + if (!typeName) return undefined; + return typeName.text.trim() || undefined; +} + +/** + * Extract the class name from a class_interface or class_implementation node. + * The first named child (type `identifier`) is the class name. + */ +function extractClassName(node: TreeSitterNode): string | null { + const id = findChild(node, "identifier"); + return id ? id.text : null; +} + +/** + * Extract @property names from a property_declaration node. + * + * AST structure: + * property_declaration + * property_attributes_declaration (optional) + * struct_declaration + * type_identifier | typedefed_specifier + * struct_declarator + * identifier (non-pointer: "BOOL isLoaded") + * pointer_declarator (pointer: "NSString *name") + * identifier + */ +function extractPropertyName(propNode: TreeSitterNode): string | null { + const structDecl = findChild(propNode, "struct_declaration"); + if (!structDecl) return null; + + const structDeclNode = findChild(structDecl, "struct_declarator"); + if (structDeclNode) { + // Unwrap pointer declarator if present + const ptr = findChild(structDeclNode, "pointer_declarator"); + const id = ptr + ? findChild(ptr, "identifier") + : findChild(structDeclNode, "identifier"); + if (id) return id.text; + } + + return findChild(structDecl, "identifier")?.text ?? null; +} + +// --------------------------------------------------------------------------- +// Objective-C extractor +// --------------------------------------------------------------------------- + +/** + * Objective-C extractor for tree-sitter structural analysis and call graph + * extraction. + * + * Handles: + * - @interface declarations → classes with methods and properties + * - @implementation definitions → methods / class methods + * - @property → properties + * - @protocol → surfaces as a class entry (same convention as Swift protocols) + * - #import / #include → imports + * - message expressions [receiver selector:arg] → call graph + * + * Both @interface and @implementation are merged into a single class entry + * (keyed by class name) following the same convention as the Swift/Dart/Kotlin + * extractors where extensions / protocol impls are folded into one entry. + * + * `functions[]` surfaces every method (instance and class) so the LLM agent + * has complete function-level coverage even when only the implementation is + * available (e.g., private categories with no header). + */ +export class ObjcExtractor implements LanguageExtractor { + readonly languageIds = ["objective-c"]; + + extractStructure(rootNode: TreeSitterNode): StructuralAnalysis { + const functions: StructuralAnalysis["functions"] = []; + const classes: StructuralAnalysis["classes"] = []; + const imports: StructuralAnalysis["imports"] = []; + const exports: StructuralAnalysis["exports"] = []; + + // Track methods / properties by class name so @interface and + // @implementation for the same class are merged into one class entry. + const classByName = new Map< + string, + { methods: string[]; properties: string[]; node: TreeSitterNode } + >(); + + for (let i = 0; i < rootNode.childCount; i++) { + const child = rootNode.child(i); + if (!child) continue; + + switch (child.type) { + // ----- #import / #include ----- + case "preproc_include": { + this.extractImport(child, imports); + break; + } + + // ----- @interface SomeName : SuperClass ... @end ----- + case "class_interface": { + const name = extractClassName(child); + if (!name) break; + + if (!classByName.has(name)) { + classByName.set(name, { methods: [], properties: [], node: child }); + } + const entry = classByName.get(name)!; + + // Gather @property names + for (const propDecl of findChildren(child, "property_declaration")) { + const propName = extractPropertyName(propDecl); + if (propName && !entry.properties.includes(propName)) { + entry.properties.push(propName); + } + } + + // Gather method declarations + for (const methodDecl of findChildren(child, "method_declaration")) { + const methodName = extractMethodName(methodDecl); + if (!entry.methods.includes(methodName)) { + entry.methods.push(methodName); + } + functions.push({ + name: methodName, + lineRange: lineRange(methodDecl), + params: extractMethodParams(methodDecl), + returnType: extractReturnType(methodDecl), + }); + } + break; + } + + // ----- @implementation SomeName ... @end ----- + case "class_implementation": { + const name = extractClassName(child); + if (!name) break; + + if (!classByName.has(name)) { + classByName.set(name, { methods: [], properties: [], node: child }); + } + const entry = classByName.get(name)!; + + // Each implementation_definition wraps a method_definition + for (const implDef of findChildren( + child, + "implementation_definition", + )) { + const methodDef = findChild(implDef, "method_definition"); + if (!methodDef) continue; + + const methodName = extractMethodName(methodDef); + if (!entry.methods.includes(methodName)) { + entry.methods.push(methodName); + } + + functions.push({ + name: methodName, + lineRange: lineRange(methodDef), + params: extractMethodParams(methodDef), + returnType: extractReturnType(methodDef), + }); + } + break; + } + + // ----- @protocol ProtocolName ... @end ----- + // Protocols are surfaced as classes (same convention as Swift protocols) + case "protocol_declaration": { + const name = extractClassName(child); + if (!name) break; + + const methods: string[] = []; + for (const methodDecl of findChildren(child, "method_declaration")) { + const methodName = extractMethodName(methodDecl); + if (!methods.includes(methodName)) methods.push(methodName); + functions.push({ + name: methodName, + lineRange: lineRange(methodDecl), + params: extractMethodParams(methodDecl), + returnType: extractReturnType(methodDecl), + }); + } + classes.push({ + name, + lineRange: lineRange(child), + methods, + properties: [], + }); + break; + } + + default: + break; + } + } + + // Flush class map → classes array (in declaration order) + for (const [name, entry] of classByName) { + classes.push({ + name, + lineRange: lineRange(entry.node), + methods: entry.methods, + properties: entry.properties, + }); + } + + // ObjC has no formal export keyword — public top-level classes and their + // methods are implicitly exported (header file convention). + for (const cls of classes) { + exports.push({ name: cls.name, lineNumber: cls.lineRange[0] }); + } + + return { functions, classes, imports, exports }; + } + + // --------------------------------------------------------------------------- + // Call graph + // --------------------------------------------------------------------------- + + extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] { + const calls: CallGraphEntry[] = []; + const callerStack: string[] = []; + + const walk = (node: TreeSitterNode) => { + switch (node.type) { + // Track current method scope + case "method_definition": { + const name = extractMethodBaseName(node); + callerStack.push(name); + // Walk body only (compound_statement) + const body = findChild(node, "compound_statement"); + if (body) walk(body); + callerStack.pop(); + return; + } + + // ObjC message sends: [receiver selector:arg] + case "message_expression": { + const caller = callerStack[callerStack.length - 1]; + if (caller) { + // The receiver is the first named child; the selector keyword + // is the second named child (an identifier). + let receiverSeen = false; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child) continue; + if (!receiverSeen && child.isNamed) { + receiverSeen = true; + continue; // skip receiver + } + if (child.type === "identifier" && receiverSeen) { + calls.push({ + caller, + callee: child.text, + lineNumber: node.startPosition.row + 1, + }); + break; + } + } + } + // Recurse into nested message expressions (block args, etc.) + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) walk(child); + } + return; + } + + // C-style function calls (NSLog, dispatch_once, etc.) + case "call_expression": { + const caller = callerStack[callerStack.length - 1]; + if (caller) { + const calleeId = findChild(node, "identifier"); + if (calleeId) { + calls.push({ + caller, + callee: calleeId.text, + lineNumber: node.startPosition.row + 1, + }); + } + } + break; + } + + default: + break; + } + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) walk(child); + } + }; + + walk(rootNode); + return calls; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private extractImport( + node: TreeSitterNode, + imports: StructuralAnalysis["imports"], + ): void { + // preproc_include children: + // system_lib_string → + // string_literal → "MyHeader.h" + let source: string | null = null; + + const sysLib = findChild(node, "system_lib_string"); + if (sysLib) { + source = sysLib.text; // includes < > + } else { + const strLit = findChild(node, "string_literal"); + if (strLit) { + // Strip surrounding quotes + source = strLit.text.replace(/^"|"$/g, ""); + } + } + + if (source) { + imports.push({ + source, + specifiers: [], + lineNumber: node.startPosition.row + 1, + }); + } + } +} diff --git a/understand-anything-plugin/pnpm-lock.yaml b/understand-anything-plugin/pnpm-lock.yaml index 8cd5f67ca..71d4c8283 100644 --- a/understand-anything-plugin/pnpm-lock.yaml +++ b/understand-anything-plugin/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: tree-sitter-javascript: specifier: ^0.25.0 version: 0.25.0 + tree-sitter-objc: + specifier: 3.0.2 + version: 3.0.2 tree-sitter-php: specifier: ^0.23.11 version: 0.23.12 @@ -1680,6 +1683,14 @@ packages: tree-sitter: optional: true + tree-sitter-objc@3.0.2: + resolution: {integrity: sha512-Hs0ohmx1u5M+0K7efoW+dv/corhBsfjftfIYLtp7dSGeJ+Zj4c33tDIboBYLs6qijRlz6wtHFxa0YX+FibLulA==} + peerDependencies: + tree-sitter: ^0.22.1 + peerDependenciesMeta: + tree-sitter: + optional: true + tree-sitter-php@0.23.12: resolution: {integrity: sha512-VwkBVOahhC2NYXK/Fuqq30NxuL/6c2hmbxEF4jrB7AyR5rLc7nT27mzF3qoi+pqx9Gy2AbXnGezF7h4MeM6YRA==} peerDependencies: @@ -3460,6 +3471,12 @@ snapshots: node-addon-api: 8.7.0 node-gyp-build: 4.8.4 + tree-sitter-objc@3.0.2: + dependencies: + node-addon-api: 8.7.0 + node-gyp-build: 4.8.4 + tree-sitter-c: 0.23.6 + tree-sitter-php@0.23.12: dependencies: node-addon-api: 8.7.0 diff --git a/understand-anything-plugin/pnpm-workspace.yaml b/understand-anything-plugin/pnpm-workspace.yaml index 19a92f214..dc0bfb345 100644 --- a/understand-anything-plugin/pnpm-workspace.yaml +++ b/understand-anything-plugin/pnpm-workspace.yaml @@ -9,6 +9,7 @@ allowBuilds: tree-sitter-go: true tree-sitter-java: true tree-sitter-javascript: true + tree-sitter-objc: true tree-sitter-php: true tree-sitter-python: true tree-sitter-ruby: true diff --git a/understand-anything-plugin/skills/understand/scan-project.mjs b/understand-anything-plugin/skills/understand/scan-project.mjs index 83ccc7d19..0bcd57f2c 100644 --- a/understand-anything-plugin/skills/understand/scan-project.mjs +++ b/understand-anything-plugin/skills/understand/scan-project.mjs @@ -121,6 +121,8 @@ const LANGUAGE_BY_EXT = Object.freeze({ '.kts': 'kotlin', '.cs': 'csharp', '.swift': 'swift', + '.m': 'objective-c', + '.mm': 'objective-c', '.lua': 'lua', // Ruby / PHP '.rb': 'ruby',