feat(extraction): model union declarations distinctly

This commit is contained in:
ctype_lab
2026-08-06 17:10:52 +09:00
parent 86854cd0d7
commit 6978acc92e
7 changed files with 84 additions and 44 deletions
+22 -6
View File
@@ -1194,11 +1194,11 @@ impl Describe for Reg {
`;
const result = extractFromSource('reg.rs', code);
// A union is a type definition, not an alias — it must be a node, or the
// impl below has no source endpoint to hang off.
// A union is a first-class type definition, not an alias — it must be a
// node, or the impl below has no source endpoint to hang off.
const reg = result.nodes.find((n) => n.name === 'Reg');
expect(reg).toBeDefined();
expect(reg?.kind).toBe('struct');
expect(reg?.kind).toBe('union');
const implRef = result.unresolvedReferences.find(
(r) => r.referenceKind === 'implements' && r.referenceName === 'Describe'
@@ -5704,7 +5704,7 @@ static unsigned int hdr_raw(union packet_hdr *h) { return h->raw; }
const hdr = result.nodes.find((n) => n.name === 'packet_hdr');
expect(hdr).toBeDefined();
expect(hdr?.kind).toBe('struct');
expect(hdr?.kind).toBe('union');
// Same rule as `struct Foo;`: bodiless is a forward declaration, so it must
// not mint a phantom node beside the real definition.
@@ -5725,7 +5725,7 @@ typedef union {
const result = extractFromSource('word.c', code);
const word = result.nodes.find((n) => n.name === 'word_t');
expect(word?.kind).toBe('struct');
expect(word?.kind).toBe('union');
// Resolved through the typedef the same way `typedef struct { … } X;` is,
// so the anonymous union body does not become its own node.
expect(result.nodes.some((n) => n.name === '<anonymous>')).toBe(false);
@@ -5742,7 +5742,7 @@ union Value {
const result = extractFromSource('value.cpp', code);
const value = result.nodes.find((n) => n.name === 'Value');
expect(value?.kind).toBe('struct');
expect(value?.kind).toBe('union');
const asInt = result.nodes.find((n) => n.name === 'as_int');
expect(asInt).toBeDefined();
@@ -8446,6 +8446,22 @@ void helperFunction(int count) {
expect(imports).toContain('MyClass.h');
});
it('extracts union declarations as first-class union nodes', () => {
const code = `
typedef union {
unsigned int raw;
float value;
} NumberBits;
union opaque_bits;
`;
const result = extractFromSource('NumberBits.m', code);
const numberBits = result.nodes.find((n) => n.name === 'NumberBits');
expect(numberBits?.kind).toBe('union');
expect(result.nodes.some((n) => n.name === 'opaque_bits')).toBe(false);
});
it('should record inheritance and protocol conformance', () => {
const result = extractFromSource('App.m', sample);
const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends');
+12 -11
View File
@@ -186,11 +186,10 @@ export const cExtractor: LanguageExtractor = {
classTypes: [],
methodTypes: [],
interfaceTypes: [],
// `union U { … };` is a type DEFINITION, same as `struct U { … };` — it
// declares a named type whose members other code refers to. Extracted with
// kind `struct` because NodeKind has no `union`; a bodiless `union U;` is a
// forward declaration and still falls out via extractStruct's body guard.
structTypes: ['struct_specifier', 'union_specifier'],
structTypes: ['struct_specifier'],
// A bodiless `union U;` is a forward declaration; the aggregate extractor
// applies the same body requirement it uses for C structs.
unionTypes: ['union_specifier'],
enumTypes: ['enum_specifier'],
enumMemberTypes: ['enumerator'],
typeAliasTypes: ['type_definition'], // typedef
@@ -219,10 +218,11 @@ export const cExtractor: LanguageExtractor = {
if (!child) continue;
if (child.type === 'enum_specifier' && getChildByField(child, 'body')) return 'enum';
if (
(child.type === 'struct_specifier' || child.type === 'union_specifier') &&
child.type === 'struct_specifier' &&
getChildByField(child, 'body')
)
return 'struct';
if (child.type === 'union_specifier' && getChildByField(child, 'body')) return 'union';
}
return undefined;
},
@@ -1561,10 +1561,10 @@ export const cppExtractor: LanguageExtractor = {
skipBodilessClass: true,
methodTypes: ['function_definition'],
interfaceTypes: [],
// See the C extractor: a named `union U { … };` is a definition, not an
// alias. C++ unions additionally carry member functions, which extract
// through the same body walk as a struct's.
structTypes: ['struct_specifier', 'union_specifier'],
structTypes: ['struct_specifier'],
// C++ unions additionally carry member functions, which extract through the
// same aggregate-body walk as structs while preserving their distinct kind.
unionTypes: ['union_specifier'],
enumTypes: ['enum_specifier'],
enumMemberTypes: ['enumerator'],
typeAliasTypes: ['type_definition', 'alias_declaration'], // typedef and using
@@ -1601,10 +1601,11 @@ export const cppExtractor: LanguageExtractor = {
if (!child) continue;
if (child.type === 'enum_specifier' && getChildByField(child, 'body')) return 'enum';
if (
(child.type === 'struct_specifier' || child.type === 'union_specifier') &&
child.type === 'struct_specifier' &&
getChildByField(child, 'body')
)
return 'struct';
if (child.type === 'union_specifier' && getChildByField(child, 'body')) return 'union';
}
return undefined;
},
+5 -8
View File
@@ -102,9 +102,9 @@ export const objcExtractor: LanguageExtractor = {
methodTypes: ['method_definition'],
interfaceTypes: ['protocol_declaration'],
interfaceKind: 'protocol',
// Objective-C is a C superset: `union U { … };` is a definition, same as in
// the C extractor.
structTypes: ['struct_specifier', 'union_specifier'],
structTypes: ['struct_specifier'],
// Objective-C is a C superset: union declarations preserve their own kind.
unionTypes: ['union_specifier'],
enumTypes: ['enum_specifier'],
enumMemberTypes: ['enumerator'],
typeAliasTypes: ['type_definition'],
@@ -130,12 +130,9 @@ export const objcExtractor: LanguageExtractor = {
const child = node.namedChild(i);
if (!child) continue;
if (child.type === 'enum_specifier' && getChildByField(child, 'body')) return 'enum';
// `typedef union { … } name;` resolves like `typedef struct` — see the C extractor.
if (
(child.type === 'struct_specifier' || child.type === 'union_specifier') &&
getChildByField(child, 'body')
)
if (child.type === 'struct_specifier' && getChildByField(child, 'body'))
return 'struct';
if (child.type === 'union_specifier' && getChildByField(child, 'body')) return 'union';
}
return undefined;
},
+4 -4
View File
@@ -41,10 +41,10 @@ export const rustExtractor: LanguageExtractor = {
classTypes: [], // Rust has impl blocks
methodTypes: ['function_item', 'function_signature_item'],
interfaceTypes: ['trait_item'],
// `union U { … }` is a definition like `struct U { … }` — same `body:`
// (`field_declaration_list`) and the same `impl Trait for U` attachment
// point. Extracted with kind `struct` because NodeKind has no `union`.
structTypes: ['struct_item', 'union_item'],
structTypes: ['struct_item'],
// Unions share struct member syntax and impl attachment, but retain their
// distinct semantic kind in the graph.
unionTypes: ['union_item'],
enumTypes: ['enum_item'],
enumMemberTypes: ['enum_variant'],
typeAliasTypes: ['type_item'], // Rust type aliases
+2
View File
@@ -102,6 +102,8 @@ export interface LanguageExtractor {
interfaceTypes: string[];
/** Node types that represent structs */
structTypes: string[];
/** Node types that represent unions */
unionTypes?: string[];
/** Node types that represent enums */
enumTypes: string[];
/** Node types that represent enum members/cases (e.g. Swift: 'enum_entry', Rust: 'enum_variant') */
+38 -15
View File
@@ -1066,6 +1066,11 @@ export class TreeSitterExtractor {
this.extractStruct(node);
skipChildren = true; // extractStruct visits body children
}
// Check for union declarations
else if (this.extractor.unionTypes?.includes(nodeType)) {
this.extractUnion(node);
skipChildren = true; // extractUnion visits body children
}
// Check for enum declarations
else if (this.extractor.enumTypes.includes(nodeType)) {
this.extractEnum(node);
@@ -1487,7 +1492,7 @@ export class TreeSitterExtractor {
/**
* Check if the current node stack indicates we are inside a class-like node
* (class, struct, interface, trait). File nodes do not count as class-like.
* (class, struct, union, interface, trait). File nodes do not count as class-like.
*/
private isInsideClassLikeNode(): boolean {
if (this.nodeStack.length === 0) return false;
@@ -1498,6 +1503,7 @@ export class TreeSitterExtractor {
return (
parentNode.kind === 'class' ||
parentNode.kind === 'struct' ||
parentNode.kind === 'union' ||
parentNode.kind === 'interface' ||
parentNode.kind === 'trait' ||
parentNode.kind === 'enum' ||
@@ -1807,7 +1813,7 @@ export class TreeSitterExtractor {
(n) =>
n.name === receiverType &&
n.filePath === this.filePath &&
(n.kind === 'struct' || n.kind === 'class' || n.kind === 'enum' || n.kind === 'trait')
(n.kind === 'struct' || n.kind === 'union' || n.kind === 'class' || n.kind === 'enum' || n.kind === 'trait')
);
if (ownerNode) {
this.edges.push({
@@ -1873,6 +1879,16 @@ export class TreeSitterExtractor {
* Extract a struct
*/
private extractStruct(node: SyntaxNode): void {
this.extractAggregate(node, 'struct');
}
/** Extract a union while sharing the member-walk behavior of aggregate types. */
private extractUnion(node: SyntaxNode): void {
this.extractAggregate(node, 'union');
}
/** Extract a struct-like declaration without conflating its semantic kind. */
private extractAggregate(node: SyntaxNode, kind: 'struct' | 'union'): void {
if (!this.extractor) return;
// Skip forward declarations and type references (no body = not a definition)
@@ -1886,24 +1902,24 @@ export class TreeSitterExtractor {
const visibility = this.extractor.getVisibility?.(node);
const isExported = this.extractor.isExported?.(node, this.source);
const structNode = this.createNode('struct', name, node, {
const aggregateNode = this.createNode(kind, name, node, {
docstring,
visibility,
isExported,
});
if (!structNode) return;
if (!aggregateNode) return;
// Extract inheritance (e.g. Swift: struct HTTPMethod: RawRepresentable)
this.extractInheritance(node, structNode.id);
this.extractInheritance(node, aggregateNode.id);
// C# primary-constructor parameter dependencies (`struct P(int x)`, and
// `record struct M(decimal Amount)` which the grammar nests here).
this.extractCsharpPrimaryCtorParamRefs(node, structNode.id);
this.extractCsharpPrimaryCtorParamRefs(node, aggregateNode.id);
// Push to stack for field extraction (bodiless positional records have
// no members to visit)
if (body) {
this.nodeStack.push(structNode.id);
this.nodeStack.push(aggregateNode.id);
for (let i = 0; i < body.namedChildCount; i++) {
const child = body.namedChild(i);
if (child) {
@@ -2905,17 +2921,20 @@ export class TreeSitterExtractor {
// (e.g. Go: `type Foo struct { ... }` is a type_spec wrapping struct_type)
const resolvedKind = this.extractor.resolveTypeAliasKind?.(node, this.source);
if (resolvedKind === 'struct') {
const structNode = this.createNode('struct', name, node, { docstring, isExported });
if (!structNode) return true;
if (resolvedKind === 'struct' || resolvedKind === 'union') {
const aggregateNode = this.createNode(resolvedKind, name, node, { docstring, isExported });
if (!aggregateNode) return true;
// Visit body children for field extraction
this.nodeStack.push(structNode.id);
// Try Go-style 'type' field first, then find inner struct child (C typedef struct)
this.nodeStack.push(aggregateNode.id);
// Try Go-style 'type' field first, then find the matching inner aggregate child.
const typeChild = getChildByField(node, 'type')
|| this.findChildByTypes(node, this.extractor.structTypes);
|| this.findChildByTypes(
node,
resolvedKind === 'union' ? (this.extractor.unionTypes ?? []) : this.extractor.structTypes
);
if (typeChild) {
// Extract struct embedding (e.g. Go: `type DB struct { *Head; Queryable }`)
this.extractInheritance(typeChild, structNode.id);
this.extractInheritance(typeChild, aggregateNode.id);
const body = getChildByField(typeChild, this.extractor.bodyField) || typeChild;
for (let i = 0; i < body.namedChildCount; i++) {
const child = body.namedChild(i);
@@ -5271,6 +5290,10 @@ export class TreeSitterExtractor {
this.extractStruct(node);
return;
}
if (this.extractor!.unionTypes?.includes(nodeType)) {
this.extractUnion(node);
return;
}
if (this.extractor!.enumTypes.includes(nodeType)) {
this.extractEnum(node);
return;
@@ -5745,7 +5768,7 @@ export class TreeSitterExtractor {
*/
private findNodeByName(name: string): string | undefined {
for (const node of this.nodes) {
if (node.name === name && (node.kind === 'struct' || node.kind === 'enum' || node.kind === 'class')) {
if (node.name === name && (node.kind === 'struct' || node.kind === 'union' || node.kind === 'enum' || node.kind === 'class')) {
return node.id;
}
}
+1
View File
@@ -42,6 +42,7 @@ export const NODE_KINDS = [
'export',
'route',
'component',
'union',
] as const;
export type NodeKind = (typeof NODE_KINDS)[number];