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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user