feat(symbol-table): add class name lookup index (#707)

This commit is contained in:
Deepak Chauhan 2026-04-07 17:59:50 +05:30 • committed by GitHub
parent 1c8ae5eb46
commit b73233d232
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 159 additions and 1 deletions

View file

@ -88,6 +88,14 @@ export interface SymbolTable {
*/
lookupMethodByOwner: (ownerNodeId: string, methodName: string) => SymbolDefinition | undefined;
/**
* Look up class-like definitions (Class, Struct, Interface, Enum, Record) by name.
* O(1) via dedicated eagerly-populated index keyed by symbol name.
* Returns all matching definitions across files (e.g. partial classes).
* Used by Phase 1 semantic-model tasks to replace filtered lookupFuzzy calls.
*/
lookupClassByName: (name: string) => SymbolDefinition[];
/**
* Debugging: See how many symbols are tracked
*/
@ -122,7 +130,12 @@ export const createSymbolTable = (): SymbolTable => {
// Method symbols with ownerId are indexed. Supports overloads (array values).
const methodByOwner = new Map<string, SymbolDefinition[]>();
// 6. Eagerly-populated Class-type Index — keyed by symbol name.
// Only Class, Struct, Interface, Enum, Record symbols are indexed.
const classByName = new Map<string, SymbolDefinition[]>();
const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor']);
const CLASS_TYPES = new Set(['Class', 'Struct', 'Interface', 'Enum', 'Record']);
const add = (
filePath: string,
@ -194,6 +207,16 @@ export const createSymbolTable = (): SymbolTable => {
}
}
// C3. Class-like types go to classByName index (in addition to globalIndex).
if (CLASS_TYPES.has(type)) {
const existing = classByName.get(name);
if (existing) {
existing.push(def);
} else {
classByName.set(name, [def]);
}
}
// D. Invalidate the lazy callable index only when adding callable types
if (CALLABLE_TYPES.has(type)) {
callableIndex = null;
@ -254,6 +277,10 @@ export const createSymbolTable = (): SymbolTable => {
return defs[0];
};
const lookupClassByName = (name: string): SymbolDefinition[] => {
return classByName.get(name) ?? [];
};
const getStats = () => ({
fileCount: fileIndex.size,
globalSymbolCount: globalIndex.size,
@ -265,6 +292,7 @@ export const createSymbolTable = (): SymbolTable => {
callableIndex = null;
fieldByOwner.clear();
methodByOwner.clear();
classByName.clear();
};
return {
@ -276,6 +304,7 @@ export const createSymbolTable = (): SymbolTable => {
lookupFuzzyCallable,
lookupFieldByOwner,
lookupMethodByOwner,
lookupClassByName,
getStats,
clear,
};

View file

@ -465,7 +465,7 @@ describe('SymbolTable', () => {
});
describe('clear', () => {
it('resets all state including fieldByOwner and methodByOwner', () => {
it('resets all state including fieldByOwner, methodByOwner, and classByName', () => {
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
table.add('src/b.ts', 'bar', 'func:bar', 'Function');
table.add('src/models.ts', 'address', 'prop:address', 'Property', {
@ -476,6 +476,7 @@ describe('SymbolTable', () => {
returnType: 'void',
ownerId: 'class:User',
});
table.add('src/models.ts', 'User', 'class:User', 'Class');
table.clear();
expect(table.getStats()).toEqual({ fileCount: 0, globalSymbolCount: 0 });
expect(table.lookupExact('src/a.ts', 'foo')).toBeUndefined();
@ -483,6 +484,7 @@ describe('SymbolTable', () => {
expect(table.lookupFieldByOwner('class:User', 'address')).toBeUndefined();
expect(table.lookupMethodByOwner('class:User', 'save')).toBeUndefined();
expect(table.lookupFuzzyCallable('foo')).toEqual([]);
expect(table.lookupClassByName('User')).toEqual([]);
});
it('allows re-adding after clear', () => {
@ -713,4 +715,131 @@ describe('SymbolTable', () => {
expect(table.lookupFieldByOwner('class:A\0id', '')).toBeUndefined();
});
});
describe('lookupClassByName', () => {
it('returns Class definitions by name', () => {
table.add('src/models.ts', 'User', 'class:User', 'Class');
const results = table.lookupClassByName('User');
expect(results).toHaveLength(1);
expect(results[0]).toEqual({
nodeId: 'class:User',
filePath: 'src/models.ts',
type: 'Class',
});
});
it('returns Struct definitions by name', () => {
table.add('src/models.rs', 'Point', 'struct:Point', 'Struct');
const results = table.lookupClassByName('Point');
expect(results).toHaveLength(1);
expect(results[0].type).toBe('Struct');
});
it('returns Interface definitions by name', () => {
table.add('src/types.ts', 'Serializable', 'iface:Serializable', 'Interface');
const results = table.lookupClassByName('Serializable');
expect(results).toHaveLength(1);
expect(results[0].type).toBe('Interface');
});
it('returns Enum definitions by name', () => {
table.add('src/types.ts', 'Color', 'enum:Color', 'Enum');
const results = table.lookupClassByName('Color');
expect(results).toHaveLength(1);
expect(results[0].type).toBe('Enum');
});
it('returns Record definitions by name', () => {
table.add('src/models.java', 'Config', 'record:Config', 'Record');
const results = table.lookupClassByName('Config');
expect(results).toHaveLength(1);
expect(results[0].type).toBe('Record');
});
it('does NOT include Function with the same name', () => {
table.add('src/models.ts', 'User', 'class:User', 'Class');
table.add('src/utils.ts', 'User', 'func:User', 'Function');
const results = table.lookupClassByName('User');
expect(results).toHaveLength(1);
expect(results[0].type).toBe('Class');
expect(results[0].nodeId).toBe('class:User');
});
it('does NOT include Method, Variable, Property, or Constructor', () => {
table.add('src/a.ts', 'Foo', 'method:Foo', 'Method');
table.add('src/a.ts', 'Bar', 'var:Bar', 'Variable');
table.add('src/a.ts', 'Baz', 'prop:Baz', 'Property');
table.add('src/a.ts', 'Qux', 'ctor:Qux', 'Constructor');
expect(table.lookupClassByName('Foo')).toEqual([]);
expect(table.lookupClassByName('Bar')).toEqual([]);
expect(table.lookupClassByName('Baz')).toEqual([]);
expect(table.lookupClassByName('Qux')).toEqual([]);
});
it('does NOT include other type-like labels outside the allowed class set', () => {
table.add('src/a.rs', 'User', 'trait:User', 'Trait');
table.add('src/a.ts', 'User', 'type:User', 'Type');
expect(table.lookupClassByName('User')).toEqual([]);
});
it('returns multiple classes with the same name from different files', () => {
table.add('src/models/user.ts', 'User', 'class:user:User', 'Class');
table.add('src/dto/user.ts', 'User', 'class:dto:User', 'Class');
const results = table.lookupClassByName('User');
expect(results).toHaveLength(2);
expect(results[0].filePath).toBe('src/models/user.ts');
expect(results[1].filePath).toBe('src/dto/user.ts');
});
it('returns empty array for unknown name', () => {
table.add('src/models.ts', 'User', 'class:User', 'Class');
expect(table.lookupClassByName('NonExistent')).toEqual([]);
});
it('returns empty array for empty table', () => {
expect(table.lookupClassByName('User')).toEqual([]);
});
it('after clear(), returns empty array', () => {
table.add('src/models.ts', 'User', 'class:User', 'Class');
expect(table.lookupClassByName('User')).toHaveLength(1);
table.clear();
expect(table.lookupClassByName('User')).toEqual([]);
});
it('returns mixed class-like types with the same name', () => {
// e.g. a Class and an Interface both named 'Comparable' in different files
table.add('src/base.ts', 'Comparable', 'class:Comparable', 'Class');
table.add('src/types.ts', 'Comparable', 'iface:Comparable', 'Interface');
const results = table.lookupClassByName('Comparable');
expect(results).toHaveLength(2);
expect(results.map((r) => r.type)).toEqual(['Class', 'Interface']);
});
it('preserves metadata on indexed class definitions', () => {
table.add('src/models.ts', 'User', 'class:User', 'Class', {
returnType: 'User',
ownerId: 'module:models',
});
const results = table.lookupClassByName('User');
expect(results).toHaveLength(1);
expect(results[0].ownerId).toBe('module:models');
});
it('class-like symbols are still available via lookupFuzzy', () => {
table.add('src/models.ts', 'User', 'class:User', 'Class');
// classByName is an additional index, not a replacement for globalIndex
expect(table.lookupFuzzy('User')).toHaveLength(1);
expect(table.lookupClassByName('User')).toHaveLength(1);
});
it('allows re-adding after clear and returns correct results', () => {
table.add('src/models.ts', 'User', 'class:User:v1', 'Class');
table.clear();
table.add('src/models.ts', 'User', 'class:User:v2', 'Class');
const results = table.lookupClassByName('User');
expect(results).toHaveLength(1);
expect(results[0].nodeId).toBe('class:User:v2');
});
});
});