feat: add Objective-C ingestion support

This commit is contained in:
realsnake 2026-04-06 22:49:57 +08:00
parent cb772b9e29
commit 9a33fd82c7
14 changed files with 359 additions and 6 deletions

View file

@ -43,6 +43,7 @@ const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
[SupportedLanguages.Dart]: ['.dart'],
[SupportedLanguages.Vue]: ['.vue'],
[SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
[SupportedLanguages.ObjectiveC]: ['.m', '.mm'],
} satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness
/** Pre-built reverse lookup: extension → language (built once at module load). */
@ -101,6 +102,7 @@ const SYNTAX_MAP: Record<SupportedLanguages, string> = {
[SupportedLanguages.Dart]: 'dart',
[SupportedLanguages.Vue]: 'typescript',
[SupportedLanguages.Cobol]: 'cobol',
[SupportedLanguages.ObjectiveC]: 'objc',
} satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness
/** Non-code file extensions → Prism-compatible syntax identifiers */

View file

@ -22,4 +22,6 @@ export enum SupportedLanguages {
Vue = 'vue',
/** Standalone regex processor — no tree-sitter, no LanguageProvider. */
Cobol = 'cobol',
/** Objective-C: uses tree-sitter-objc for .m/.mm implementation files. */
ObjectiveC = 'objectivec',
}

View file

@ -33,6 +33,7 @@
"tree-sitter-go": "^0.23.0",
"tree-sitter-java": "^0.23.5",
"tree-sitter-javascript": "^0.23.0",
"tree-sitter-objc": "^3.0.2",
"tree-sitter-php": "^0.23.0",
"tree-sitter-python": "0.23.4",
"tree-sitter-ruby": "^0.23.1",
@ -5258,6 +5259,54 @@
"license": "MIT",
"optional": true
},
"node_modules/tree-sitter-objc": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tree-sitter-objc/-/tree-sitter-objc-3.0.2.tgz",
"integrity": "sha512-Hs0ohmx1u5M+0K7efoW+dv/corhBsfjftfIYLtp7dSGeJ+Zj4c33tDIboBYLs6qijRlz6wtHFxa0YX+FibLulA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4",
"tree-sitter-c": "^0.23.4"
},
"peerDependencies": {
"tree-sitter": "^0.22.1"
},
"peerDependenciesMeta": {
"tree-sitter": {
"optional": true
}
}
},
"node_modules/tree-sitter-objc/node_modules/node-addon-api": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/tree-sitter-objc/node_modules/tree-sitter-c": {
"version": "0.23.6",
"resolved": "https://registry.npmjs.org/tree-sitter-c/-/tree-sitter-c-0.23.6.tgz",
"integrity": "sha512-0dxXKznVyUA0s6PjNolJNs2yF87O5aL538A/eR6njA5oqX3C3vH4vnx3QdOKwuUdpKEcFdHuiDpRKLLCA/tjvQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"peerDependencies": {
"tree-sitter": "^0.22.1"
},
"peerDependenciesMeta": {
"tree-sitter": {
"optional": true
}
}
},
"node_modules/tree-sitter-php": {
"version": "0.23.12",
"resolved": "https://registry.npmjs.org/tree-sitter-php/-/tree-sitter-php-0.23.12.tgz",

View file

@ -75,6 +75,7 @@
"tree-sitter-go": "^0.23.0",
"tree-sitter-java": "^0.23.5",
"tree-sitter-javascript": "^0.23.0",
"tree-sitter-objc": "^3.0.2",
"tree-sitter-php": "^0.23.0",
"tree-sitter-python": "0.23.4",
"tree-sitter-ruby": "^0.23.1",
@ -88,7 +89,6 @@
"tree-sitter-swift": "^0.6.0"
},
"devDependencies": {
"gitnexus-shared": "file:../gitnexus-shared",
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
@ -96,6 +96,7 @@
"@types/node": "^20.0.0",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",
"tsx": "^4.0.0",
"typescript": "^5.4.5",
"vitest": "^4.0.18"

View file

@ -228,6 +228,7 @@ export const ENTRY_POINT_PATTERNS = {
],
[SupportedLanguages.Vue]: [], // Vue uses TypeScript queries — entry points handled via TS patterns
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no tree-sitter entry points
[SupportedLanguages.ObjectiveC]: [], // ObjC has no special entry point patterns
} satisfies Record<SupportedLanguages, RegExp[]>;
/** Pre-computed merged patterns (universal + language-specific) to avoid per-call array allocation. */

View file

@ -893,6 +893,7 @@ export const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE = {
],
[SupportedLanguages.Vue]: [], // Vue uses TypeScript AST framework detection
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no AST framework patterns
[SupportedLanguages.ObjectiveC]: [], // ObjC has no dedicated AST framework patterns
} satisfies Record<SupportedLanguages, AstFrameworkPatternConfig[]>;
/** Pre-lowercased patterns for O(1) pattern matching at runtime */

View file

@ -25,6 +25,7 @@ import { swiftProvider } from './swift.js';
import { dartProvider } from './dart.js';
import { vueProvider } from './vue.js';
import { cobolProvider } from './cobol.js';
import { objcProvider } from './objc.js';
export const providers = {
[SupportedLanguages.JavaScript]: javascriptProvider,
@ -43,6 +44,7 @@ export const providers = {
[SupportedLanguages.Dart]: dartProvider,
[SupportedLanguages.Vue]: vueProvider,
[SupportedLanguages.Cobol]: cobolProvider,
[SupportedLanguages.ObjectiveC]: objcProvider,
} satisfies Record<SupportedLanguages, LanguageProvider>;
/** Get provider by language enum (always succeeds for SupportedLanguages). */

View file

@ -0,0 +1,91 @@
/**
* Objective-C Language Provider
*
* Assembles all Objective-C-specific ingestion capabilities into a single
* LanguageProvider, following the Strategy pattern used by the pipeline.
*
* Key Objective-C traits:
* - importSemantics: 'wildcard' (ObjC imports entire modules via #import)
* - heritageDefaultEdge: 'EXTENDS' (single class inheritance, multiple protocol adoption)
* - ObjC uses the same type config and export checker as C++ since they share
* similar declaration patterns for functions and global state.
* - message_expression nodes are captured as CALLS (e.g., [self doSomething])
* - class_interface / class_implementation / protocol_declaration captured as definitions.
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as cCppConfig } from '../type-extractors/c-cpp.js';
import { cCppExportChecker } from '../export-detection.js';
import { resolveCImport } from '../import-resolvers/standard.js';
import { OBJ_C_QUERIES } from '../tree-sitter-queries.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { cConfig as cFieldConfig } from '../field-extractors/configs/c-cpp.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { cMethodConfig } from '../method-extractors/configs/c-cpp.js';
const OBJC_BUILT_INS: ReadonlySet<string> = new Set([
'NSLog',
'NSLogv',
'dispatch_async',
'dispatch_sync',
'dispatch_once',
'dispatch_after',
'dispatch_group_async',
'objc_getClass',
'objc_getMetaClass',
'objc_msgSend',
'objc_msgSendSuper',
'objc_msgSend_stret',
'objc_msgSendSuper_stret',
'sel_registerName',
'protocol_getName',
'class_getName',
'class_getSuperclass',
'object_getClass',
'object_getInstanceSize',
'class_addMethod',
'class_replaceMethod',
'class_getInstanceMethod',
'class_getClassMethod',
'method_exchangeImplementations',
'imp_implementationWithBlock',
'imp_getBlock',
'imp_removeBlock',
'objc_setAssociatedObject',
'objc_getAssociatedObject',
'objc_removeAssociatedObjects',
'class_copyPropertyList',
'class_copyMethodList',
'class_copyIvarList',
'property_getName',
'ivar_getName',
'ivar_getTypeEncoding',
'method_getName',
'method_getTypeEncoding',
'method_getReturnType',
'method_getNumberOfArguments',
'method_getArgumentType',
'class_isMetaClass',
'object_isClass',
'class_respondsToSelector',
'instancesRespondToSelector',
'conformsToProtocol',
'CFRetain',
'CFRelease',
'CFAutorelease',
]);
export const objcProvider = defineLanguage({
id: SupportedLanguages.ObjectiveC,
extensions: ['.m', '.mm'],
treeSitterQueries: OBJ_C_QUERIES,
typeConfig: cCppConfig,
exportChecker: cCppExportChecker,
importResolver: resolveCImport,
importSemantics: 'wildcard',
heritageDefaultEdge: 'EXTENDS',
fieldExtractor: createFieldExtractor(cFieldConfig),
methodExtractor: createMethodExtractor(cMethodConfig),
builtInNames: OBJC_BUILT_INS,
});

View file

@ -318,6 +318,13 @@ const processParsingSequential = async (
isVueSetup = extracted.isSetup;
}
// ObjC: strip NS_ASSUME_NONNULL_* macros that break class_interface/protocol parsing
if (language === SupportedLanguages.ObjectiveC) {
parseContent = parseContent
.replace(/NS_ASSUME_NONNULL_BEGIN\s*/g, '')
.replace(/NS_ASSUME_NONNULL_END\s*/g, '');
}
try {
await loadLanguage(language, file.path);
} catch {

View file

@ -950,6 +950,82 @@ export const KOTLIN_QUERIES = `
`;
// Objective-C queries - works with tree-sitter-objc
export const OBJ_C_QUERIES = `
; Class declarations - use anchor to capture only the first identifier (class name)
; . anchor ensures we get the FIRST identifier (class name), not superclass or others
(class_interface
.
(identifier) @name) @definition.class
; Class implementations - @implementation MyClass (methods only, no heritage)
(class_implementation
.
(identifier) @name) @definition.class
; Protocol declarations - anchor to first identifier (protocol name)
(protocol_declaration
.
(identifier) @name) @definition.interface
; Categories - class extension (anonymous category = extension)
(class_interface
.
(identifier) @name
category: (identifier) @category) @definition.class
; Heritage - superclass (e.g., @interface Foo : Bar)
; . matches the FIRST identifier (Foo = class name); then explicit superclass identifier
(class_interface
.
(identifier) @heritage.class
(identifier) @heritage.extends) @heritage
; Heritage - protocol adoptions (e.g., @interface Foo <Proto1, Proto2>)
; . anchors to class name identifier; parameterized_arguments follows as sibling
(class_interface
.
(identifier) @heritage.class
(parameterized_arguments
(type_name
(type_identifier) @heritage.implements))) @heritage.impl
; Method definitions - capture only the first identifier after +/- and method_type
; Use ["+" "-"] to match both class (+) and instance (-) methods
(method_declaration
["+" "-"]
(method_type)
(identifier) @name) @definition.method
; Calls - ObjC message expressions (e.g., [self doSomething], [calc add:5 to:3])
; Use method: field to capture the method selector name(s)
(message_expression
method: (identifier) @call.name) @call
; Calls - C-style function calls (e.g., NSLog(...))
(call_expression
(identifier) @call.name) @call
; Property declarations (e.g., @property (nonatomic, strong) NSString *name)
(property_declaration
(struct_declaration
(_)
(struct_declarator
(pointer_declarator
declarator: (identifier) @name)))) @definition.property
; Instance variable declarations inside @interface { } block
(instance_variable
(struct_declaration
(_)
(struct_declarator
(pointer_declarator
declarator: (identifier) @name)))) @definition.property
; Imports - #import "path" or #import <path> (preproc_include is used by tree-sitter-objc for #import)
(preproc_include path: (_) @import.source) @import
`;
// Swift queries - works with tree-sitter-swift
export const SWIFT_QUERIES = `
; Classes
@ -1193,4 +1269,5 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.Dart]: DART_QUERIES,
[SupportedLanguages.Vue]: TYPESCRIPT_QUERIES, // Vue <script> blocks are parsed as TypeScript
[SupportedLanguages.Cobol]: '', // Standalone regex processor — no tree-sitter queries
[SupportedLanguages.ObjectiveC]: OBJ_C_QUERIES,
};

View file

@ -11,6 +11,7 @@ import Go from 'tree-sitter-go';
import Rust from 'tree-sitter-rust';
import PHP from 'tree-sitter-php';
import Ruby from 'tree-sitter-ruby';
import ObjC from 'tree-sitter-objc';
import { createRequire } from 'node:module';
import { SupportedLanguages } from 'gitnexus-shared';
import { getProvider } from '../languages/index.js';
@ -20,21 +21,24 @@ import { SymbolTable } from '../symbol-table.js';
/** Language grammar type accepted by Parser.setLanguage(). */
type TreeSitterLanguage = Parameters<typeof Parser.prototype.setLanguage>[0];
/** Extended language type that includes grammar packages with optional name property */
type GrammarLanguage = TreeSitterLanguage | { language: unknown; nodeTypeInfo?: unknown[] };
// tree-sitter-swift is an optionalDependency — may not be installed
const _require = createRequire(import.meta.url);
let Swift: TreeSitterLanguage | null = null;
let Swift: GrammarLanguage = null;
try {
Swift = _require('tree-sitter-swift');
} catch {}
// tree-sitter-dart is an optionalDependency — may not be installed
let Dart: TreeSitterLanguage | null = null;
let Dart: GrammarLanguage = null;
try {
Dart = _require('tree-sitter-dart');
} catch {}
// tree-sitter-kotlin is an optionalDependency — may not be installed
let Kotlin: TreeSitterLanguage | null = null;
let Kotlin: GrammarLanguage = null;
try {
Kotlin = _require('tree-sitter-kotlin');
} catch {}
@ -277,7 +281,7 @@ type WorkerIncomingMessage =
const parser = new Parser();
const languageMap: Record<string, TreeSitterLanguage> = {
const languageMap: Record<string, GrammarLanguage> = {
[SupportedLanguages.JavaScript]: JavaScript,
[SupportedLanguages.TypeScript]: TypeScript.typescript,
[`${SupportedLanguages.TypeScript}:tsx`]: TypeScript.tsx,
@ -294,6 +298,7 @@ const languageMap: Record<string, TreeSitterLanguage> = {
[SupportedLanguages.Vue]: TypeScript.typescript,
...(Dart ? { [SupportedLanguages.Dart]: Dart } : {}),
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
[SupportedLanguages.ObjectiveC]: ObjC,
};
/**
@ -317,7 +322,7 @@ const setLanguage = (language: SupportedLanguages, filePath: string): void => {
: language;
const lang = languageMap[key];
if (!lang) throw new Error(`Unsupported language: ${language}`);
parser.setLanguage(lang);
parser.setLanguage(lang as TreeSitterLanguage);
};
// ============================================================================
@ -1313,6 +1318,15 @@ const processFileGroup = (
isVueSetup = extracted.isSetup;
}
// ObjC: strip NS_ASSUME_NONNULL_* macros that break class_interface/protocol parsing
// tree-sitter-objc grammar doesn't handle these Apple nullability宏, causing
// @interface declarations after NS_ASSUME_NONNULL_BEGIN to become ERROR nodes
if (language === SupportedLanguages.ObjectiveC) {
parseContent = parseContent
.replace(/NS_ASSUME_NONNULL_BEGIN\s*/g, '')
.replace(/NS_ASSUME_NONNULL_END\s*/g, '');
}
clearCaches(); // Reset memoization before each new file
let tree;

View file

@ -10,6 +10,7 @@ import Go from 'tree-sitter-go';
import Rust from 'tree-sitter-rust';
import PHP from 'tree-sitter-php';
import Ruby from 'tree-sitter-ruby';
import ObjC from 'tree-sitter-objc';
import { createRequire } from 'node:module';
import { SupportedLanguages } from 'gitnexus-shared';
@ -49,6 +50,7 @@ const languageMap: Record<string, any> = {
[SupportedLanguages.Vue]: TypeScript.typescript,
...(Dart ? { [SupportedLanguages.Dart]: Dart } : {}),
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
[SupportedLanguages.ObjectiveC]: ObjC,
};
export const isLanguageAvailable = (language: SupportedLanguages): boolean =>

View file

@ -0,0 +1,34 @@
#import <Foundation/Foundation.h>
@interface Calculator : NSObject
@property (nonatomic, strong) NSString *name;
- (void)reset;
- (NSInteger)add:(NSInteger)a to:(NSInteger)b;
- (NSInteger)multiply:(NSInteger)a with:(NSInteger)b;
+ (instancetype)sharedCalculator;
@end
@implementation Calculator
- (void)reset {
NSLog(@"reset");
}
- (NSInteger)add:(NSInteger)a to:(NSInteger)b {
return a + b;
}
- (NSInteger)multiply:(NSInteger)a with:(NSInteger)b {
return a * b;
}
+ (instancetype)sharedCalculator {
Calculator *calc = [Calculator alloc];
return [calc init];
}
@end
void runCalculatorSample(void) {
Calculator *calc = [Calculator sharedCalculator];
[calc reset];
[calc add:5 to:3];
[calc multiply:2 with:4];
}

View file

@ -378,6 +378,76 @@ describe('Tree-sitter multi-language parsing', () => {
});
});
describe('Objective-C', () => {
it('parses class, method declarations if tree-sitter-objc is available', async () => {
try {
await loadLanguage(SupportedLanguages.ObjectiveC, 'simple.m');
} catch {
// tree-sitter-objc not installed — skip
return;
}
const content = readFixture('simple.m');
const provider = getProvider(SupportedLanguages.ObjectiveC);
const { matches } = parseAndQuery(parser, content, provider.treeSitterQueries);
const defs = extractDefinitions(matches);
expect(defs.length).toBeGreaterThan(0);
const names = defs.map((d) => d.name);
// Class names
expect(names).toContain('Calculator');
// Method names (keyword selectors captured as separate identifiers: add, to, multiply, with)
expect(names).toContain('reset');
expect(names).toContain('add');
expect(names).toContain('to');
expect(names).toContain('multiply');
expect(names).toContain('with');
expect(names).toContain('sharedCalculator');
});
it('captures class interface and implementation', async () => {
try {
await loadLanguage(SupportedLanguages.ObjectiveC, 'simple.m');
} catch {
return;
}
const content = readFixture('simple.m');
const provider = getProvider(SupportedLanguages.ObjectiveC);
const { matches } = parseAndQuery(parser, content, provider.treeSitterQueries);
const defs = extractDefinitions(matches);
const defTypes = defs.map((d) => d.type);
expect(defTypes).toContain('definition.class');
});
it('captures ObjC message expressions as calls', async () => {
try {
await loadLanguage(SupportedLanguages.ObjectiveC, 'simple.m');
} catch {
return;
}
const content = readFixture('simple.m');
const provider = getProvider(SupportedLanguages.ObjectiveC);
const { matches } = parseAndQuery(parser, content, provider.treeSitterQueries);
const calls: string[] = [];
for (const match of matches) {
for (const capture of match.captures) {
if (capture.name === 'call.name') calls.push(capture.node.text);
}
}
// ObjC message calls capture method names (selectors), not receivers
// [calc reset] -> reset, [calc add:5 to:3] -> add, to, [Calculator sharedCalculator] -> sharedCalculator
// C function calls like NSLog() are also captured
expect(calls).toContain('reset');
expect(calls).toContain('sharedCalculator');
expect(calls).toContain('NSLog');
expect(calls).toContain('alloc');
expect(calls).toContain('init');
});
});
describe('Swift', () => {
it('parses class, struct, protocol, and function if tree-sitter-swift is available', async () => {
try {