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