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
+14 -1
View File
@@ -37,6 +37,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
scala: 'tree-sitter-scala.wasm',
lua: 'tree-sitter-lua.wasm',
luau: 'tree-sitter-luau.wasm',
objc: 'tree-sitter-objc.wasm',
};
/**
@@ -92,6 +93,8 @@ export const EXTENSION_MAP: Record<string, Language> = {
'.sc': 'scala',
'.lua': 'lua',
'.luau': 'luau',
'.m': 'objc',
'.mm': 'objc',
};
/**
@@ -228,9 +231,10 @@ export function detectLanguage(filePath: string, source?: string): Language {
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
const lang = EXTENSION_MAP[ext] || 'unknown';
// .h files could be C or C++ — check source content for C++ features
// .h files could be C, C++, or Objective-C — check source content
if (lang === 'c' && ext === '.h' && source) {
if (looksLikeCpp(source)) return 'cpp';
if (looksLikeObjc(source)) return 'objc';
}
return lang;
@@ -245,6 +249,14 @@ function looksLikeCpp(source: string): boolean {
return /\bnamespace\b|\bclass\s+\w+\s*[:{]|\btemplate\s*<|\b(?:public|private|protected)\s*:|\bvirtual\b|\busing\s+(?:namespace\b|\w+\s*=)/.test(sample);
}
/**
* Heuristic: does a .h file contain Objective-C constructs?
*/
function looksLikeObjc(source: string): boolean {
const sample = source.substring(0, 8192);
return /@(?:interface|implementation|protocol|synthesize)\b/.test(sample);
}
/**
* Check if a language is supported (has a grammar defined).
* Returns true if the grammar exists, even if not yet loaded.
@@ -342,6 +354,7 @@ export function getLanguageDisplayName(language: Language): string {
scala: 'Scala',
lua: 'Lua',
luau: 'Luau',
objc: 'Objective-C',
yaml: 'YAML',
twig: 'Twig',
unknown: 'Unknown',
+2
View File
@@ -25,6 +25,7 @@ import { pascalExtractor } from './pascal';
import { scalaExtractor } from './scala';
import { luaExtractor } from './lua';
import { luauExtractor } from './luau';
import { objcExtractor } from './objc';
export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
typescript: typescriptExtractor,
@@ -47,4 +48,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
scala: scalaExtractor,
lua: luaExtractor,
luau: luauExtractor,
objc: objcExtractor,
};
+136
View File
@@ -0,0 +1,136 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getChildByField, getNodeText } from '../tree-sitter-helpers';
import type { ExtractorContext, LanguageExtractor } from '../tree-sitter-types';
function findCompoundStatement(node: SyntaxNode): SyntaxNode | null {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'compound_statement') {
return child;
}
}
return null;
}
/** Build ObjC selector: `greet`, `doThing:`, or `doThing:with:`. */
function extractObjcMethodName(node: SyntaxNode, source: string): string | undefined {
if (node.type !== 'method_definition' && node.type !== 'method_declaration') {
return undefined;
}
const identifiers = node.namedChildren.filter((c) => c.type === 'identifier');
if (identifiers.length === 0) return undefined;
const hasParameters = node.namedChildren.some((c) => c.type === 'method_parameter');
const firstIdentifier = identifiers[0];
if (!firstIdentifier) return undefined;
if (!hasParameters) {
return getNodeText(firstIdentifier, source);
}
return identifiers.map((id) => `${getNodeText(id, source)}:`).join('');
}
function extractObjcPropertyName(node: SyntaxNode, source: string): string | null {
if (node.type !== 'property_declaration') return null;
const structDecl = node.namedChildren.find((c) => c.type === 'struct_declaration');
if (!structDecl) return null;
const structDeclarator = structDecl.namedChildren.find((c) => c.type === 'struct_declarator');
if (!structDeclarator) return null;
let current: SyntaxNode | null = structDeclarator;
while (current) {
const inner: SyntaxNode | undefined =
getChildByField(current, 'declarator') ||
current.namedChildren.find((c) => c.type === 'identifier' || c.type === 'pointer_declarator');
if (!inner) break;
if (inner.type === 'identifier') {
return getNodeText(inner, source);
}
current = inner;
}
return null;
}
export const objcExtractor: LanguageExtractor = {
functionTypes: ['function_definition'],
// Only @interface emits a class node; @implementation reuses it via visitNode.
classTypes: ['class_interface'],
methodTypes: ['method_definition'],
interfaceTypes: ['protocol_declaration'],
interfaceKind: 'protocol',
structTypes: ['struct_specifier'],
enumTypes: ['enum_specifier'],
enumMemberTypes: ['enumerator'],
typeAliasTypes: ['type_definition'],
importTypes: ['preproc_include'],
callTypes: ['call_expression', 'message_expression'],
variableTypes: ['declaration'],
propertyTypes: ['property_declaration'],
nameField: 'declarator',
bodyField: 'body',
paramsField: 'parameters',
resolveName: extractObjcMethodName,
extractPropertyName: extractObjcPropertyName,
resolveBody: (node, bodyField) => {
const fromField = getChildByField(node, bodyField);
if (fromField) {
return fromField;
}
return findCompoundStatement(node);
},
resolveTypeAliasKind: (node, _source) => {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
if (child.type === 'enum_specifier' && getChildByField(child, 'body')) return 'enum';
if (child.type === 'struct_specifier' && getChildByField(child, 'body')) return 'struct';
}
return undefined;
},
isStatic: (node) => /^\s*\+/.test(node.text),
visitNode: (node, ctx: ExtractorContext) => {
if (node.type !== 'class_implementation') return false;
const classNameNode = node.namedChildren.find((c) => c.type === 'identifier');
if (!classNameNode) return true;
const className = getNodeText(classNameNode, ctx.source);
const classNode =
ctx.nodes.find(
(n) => n.name === className && n.filePath === ctx.filePath && n.kind === 'class'
) ?? ctx.createNode('class', className, node, {});
if (!classNode) return true;
ctx.pushScope(classNode.id);
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'implementation_definition') {
for (let j = 0; j < child.namedChildCount; j++) {
const implChild = child.namedChild(j);
if (implChild) ctx.visitNode(implChild);
}
}
}
ctx.popScope();
return true;
},
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
const systemLib = node.namedChildren.find((c: SyntaxNode) => c.type === 'system_lib_string');
if (systemLib) {
return { moduleName: getNodeText(systemLib, source).replace(/^<|>$/g, ''), signature: importText };
}
const stringLiteral = node.namedChildren.find((c: SyntaxNode) => c.type === 'string_literal');
if (stringLiteral) {
const stringContent = stringLiteral.namedChildren.find((c: SyntaxNode) => c.type === 'string_content');
if (stringContent) {
return { moduleName: getNodeText(stringContent, source), signature: importText };
}
}
return null;
},
};
+6
View File
@@ -120,6 +120,12 @@ export interface LanguageExtractor {
// --- Existing hooks ---
/** Override symbol name extraction (e.g. ObjC multi-part selectors). */
resolveName?: (node: SyntaxNode, source: string) => string | undefined;
/** Extract property name when the generic name walk fails (e.g. ObjC @property). */
extractPropertyName?: (node: SyntaxNode, source: string) => string | null;
/** Extract signature from node */
getSignature?: (node: SyntaxNode, source: string) => string | undefined;
/** Extract visibility from node */
+62 -6
View File
@@ -35,6 +35,9 @@ export { generateNodeId } from './tree-sitter-helpers';
* Extract the name from a node based on language
*/
function extractName(node: SyntaxNode, source: string, extractor: LanguageExtractor): string {
const hookName = extractor.resolveName?.(node, source);
if (hookName) return hookName;
// Try field name first
const nameNode = getChildByField(node, extractor.nameField);
if (nameNode) {
@@ -893,12 +896,12 @@ export class TreeSitterExtractor {
const visibility = this.extractor.getVisibility?.(node);
const isStatic = this.extractor.isStatic?.(node) ?? false;
// Property name is a direct identifier child
const nameNode = getChildByField(node, 'name')
|| node.namedChildren.find(c => c.type === 'identifier');
if (!nameNode) return;
const name = getNodeText(nameNode, this.source);
const hookName = this.extractor.extractPropertyName?.(node, this.source);
const nameNode = hookName
? null
: getChildByField(node, 'name') || node.namedChildren.find(c => c.type === 'identifier');
const name = hookName ?? (nameNode ? getNodeText(nameNode, this.source) : null);
if (!name) return;
// Get property type from the type child (first named child that isn't modifier or identifier)
const typeNode = node.namedChildren.find(
@@ -1463,6 +1466,23 @@ export class TreeSitterExtractor {
calleeName = `${receiverName}.${methodName}`;
}
}
} else if (node.type === 'message_expression') {
const methodField = getChildByField(node, 'method');
if (methodField) {
const methodName = getNodeText(methodField, this.source);
const receiverField = getChildByField(node, 'receiver');
const SKIP_RECEIVERS = new Set(['self', 'super']);
if (receiverField && receiverField.type !== 'message_expression') {
const receiverName = getNodeText(receiverField, this.source);
if (receiverName && !SKIP_RECEIVERS.has(receiverName)) {
calleeName = `${receiverName}.${methodName}`;
} else {
calleeName = methodName;
}
} else {
calleeName = methodName;
}
}
} else {
const func = getChildByField(node, 'function') || node.namedChild(0);
@@ -1770,6 +1790,42 @@ export class TreeSitterExtractor {
* Extract inheritance relationships
*/
private extractInheritance(node: SyntaxNode, classId: string): void {
// Objective-C @interface MyClass : NSObject <ProtoA, ProtoB>
if (node.type === 'class_interface') {
const superclass = getChildByField(node, 'superclass');
if (superclass) {
const name = getNodeText(superclass, this.source);
this.unresolvedReferences.push({
fromNodeId: classId,
referenceName: name,
referenceKind: 'extends',
line: superclass.startPosition.row + 1,
column: superclass.startPosition.column,
});
}
for (let j = 0; j < node.namedChildCount; j++) {
const argList = node.namedChild(j);
if (argList?.type !== 'parameterized_arguments') continue;
for (let k = 0; k < argList.namedChildCount; k++) {
const typeName = argList.namedChild(k);
if (!typeName) continue;
const typeId = typeName.namedChildren.find(
(c: SyntaxNode) => c.type === 'type_identifier' || c.type === 'identifier'
);
if (!typeId) continue;
const protocolName = getNodeText(typeId, this.source);
this.unresolvedReferences.push({
fromNodeId: classId,
referenceName: protocolName,
referenceKind: 'implements',
line: typeId.startPosition.row + 1,
column: typeId.startPosition.column,
});
}
}
return;
}
// Look for extends/implements clauses
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
+1
View File
@@ -24,6 +24,7 @@ const EXTENSION_RESOLUTION: Record<string, string[]> = {
csharp: ['.cs'],
php: ['.php'],
ruby: ['.rb'],
objc: ['.h', '.m', '.mm'],
};
/**
+1
View File
@@ -87,6 +87,7 @@ export const LANGUAGES = [
'scala',
'lua',
'luau',
'objc',
'yaml',
'twig',
'unknown',