/**
* Per-declaration extractor for TypeScript classes.
*
* `extractClassInfo` mutates a `DeclarationJsonBuild` with rich metadata
* derived from the class declaration: heritage clauses, generic parameters,
* properties, methods, constructors, and accessor pairs (getters/setters
* merged by name). Called by `analyzeDeclaration` in `typescript-exports.ts`
* once kind dispatch is settled.
*
* @see `typescript-extract-shared.ts` for shared helpers
*
* @module
*/
import ts from 'typescript';
import type {MemberKind, DeclarationModifier} from './types.js';
import type {DeclarationJsonBuild, MemberJsonBuild} from './declaration-build.js';
import {type Diagnostic} from './diagnostics.js';
import {parseComment, applyToDeclaration} from './tsdoc.js';
import {
detectReactivity,
extractModifiers,
getNodeLocation,
parseGenericParam,
populateCallableMember,
} from './typescript-extract-shared.js';
/**
* Extract class information with rich member metadata.
*
* @internal Used by `analyzeDeclaration` — not part of the public barrel export.
*
* @param node - the declaration AST node
* @param checker - TypeScript type checker
* @param declaration - the declaration to populate
* @param diagnostics - diagnostics collector for non-fatal issues
* @mutates declaration - adds extends, implements, genericParams, members
*/
export const extractClassInfo = (
node: ts.Node,
checker: ts.TypeChecker,
declaration: DeclarationJsonBuild,
diagnostics: Array<Diagnostic>,
): void => {
if (!ts.isClassDeclaration(node)) return;
if (node.heritageClauses) {
const extendsClause = node.heritageClauses.find(
(hc) => hc.token === ts.SyntaxKind.ExtendsKeyword,
);
if (extendsClause?.types[0]) {
declaration.extends = extendsClause.types[0].getText();
}
declaration.implements = node.heritageClauses
.filter((hc) => hc.token === ts.SyntaxKind.ImplementsKeyword)
.flatMap((hc) => hc.types.map((t) => t.getText()));
}
if (node.typeParameters?.length) {
declaration.genericParams = node.typeParameters.map(parseGenericParam);
}
// Extract members with full metadata
// Track processed constructors and methods to deduplicate overloads
let constructorProcessed = false;
const processedMethods: Set<string> = new Set();
for (const member of node.members) {
if (
ts.isPropertyDeclaration(member) ||
ts.isMethodDeclaration(member) ||
ts.isConstructorDeclaration(member)
) {
const isConstructor = ts.isConstructorDeclaration(member);
const memberName = isConstructor
? 'constructor'
: ts.isIdentifier(member.name)
? member.name.text
: member.name.getText();
if (!memberName) continue;
// Skip private fields (those starting with #)
if (memberName.startsWith('#')) continue;
// Skip private members - protected members are part of the extension API
const modifiers = ts.getModifiers(member);
if (modifiers) {
const isPrivate = modifiers.some((m) => m.kind === ts.SyntaxKind.PrivateKeyword);
if (isPrivate) continue;
}
// Skip duplicate overload declarations — only process first occurrence
if (isConstructor) {
if (constructorProcessed) continue;
constructorProcessed = true;
} else if (ts.isMethodDeclaration(member)) {
if (processedMethods.has(memberName)) continue;
processedMethods.add(memberName);
}
const memberKind: MemberKind = isConstructor
? 'constructor'
: ts.isMethodDeclaration(member)
? 'function'
: 'variable';
const memberDeclaration: MemberJsonBuild = {
name: memberName,
kind: memberKind,
};
if (ts.isPropertyDeclaration(member) && member.questionToken) {
memberDeclaration.optional = true;
}
// Extract modifiers (reuse already-extracted modifiers array)
const modifierFlags = extractModifiers(modifiers);
if (modifierFlags.length > 0) {
memberDeclaration.modifiers = modifierFlags;
}
// Extract TSDoc (applies docComment, examples, deprecated, seeAlso, since, mutates)
const memberTsdoc = parseComment(member, node.getSourceFile());
applyToDeclaration(memberDeclaration, memberTsdoc);
// Extract type information and parameters for methods and constructors
try {
if (ts.isPropertyDeclaration(member)) {
if (member.type) {
memberDeclaration.typeSignature = member.type.getText();
} else {
// Fall back to inferred type for unannotated fields (e.g., `count = $state(0)`).
const memberSymbol = checker.getSymbolAtLocation(member.name);
if (memberSymbol) {
const t = checker.getTypeOfSymbolAtLocation(memberSymbol, member);
memberDeclaration.typeSignature = checker.typeToString(t);
}
}
} else if (ts.isMethodDeclaration(member) || ts.isConstructorDeclaration(member)) {
let signatures: ReadonlyArray<ts.Signature> = [];
if (isConstructor) {
// Direct AST→signature path. Mirrors how methods (below) operate via
// the member node, but constructors lack a `member.name` to detour
// through. Works for both named and anonymous classes (the previous
// `getSymbolAtLocation(node.name)` detour silently dropped signatures
// for `export default class { ... }`).
const ctorDecls = node.members.filter(ts.isConstructorDeclaration);
// When overload signatures (no body) exist, only those count as
// public signatures — the implementation is hidden. Mirrors what
// `getConstructSignatures()` returns on the class type.
const overloadDecls = ctorDecls.filter((c) => !c.body);
const ctorsToUse = overloadDecls.length > 0 ? overloadDecls : ctorDecls;
signatures = ctorsToUse
.map((c) => checker.getSignatureFromDeclaration(c))
.filter((s): s is ts.Signature => s !== undefined);
} else {
// For methods, get call signatures from the method symbol
const memberSymbol = checker.getSymbolAtLocation(member.name);
if (memberSymbol) {
const memberType = checker.getTypeOfSymbolAtLocation(memberSymbol, member);
signatures = memberType.getCallSignatures();
}
}
populateCallableMember(
memberDeclaration,
signatures,
checker,
memberTsdoc,
member,
memberName,
diagnostics,
!isConstructor,
);
}
} catch (err) {
memberDeclaration.partial = true;
const loc = getNodeLocation(member);
const className = node.name?.text ?? '<anonymous>';
diagnostics.push({
kind: 'class_member_failed',
file: loc.file,
line: loc.line,
column: loc.column,
message: `Failed to analyze member "${memberName}" in class "${className}": ${err instanceof Error ? err.message : String(err)}`,
severity: 'warning',
className,
memberName,
});
}
// Outside the try so reactivity is still captured if type extraction throws.
if (ts.isPropertyDeclaration(member)) {
const reactivity = detectReactivity(member.initializer);
if (reactivity) memberDeclaration.reactivity = reactivity;
}
(declaration.members ??= []).push(memberDeclaration);
}
}
// Extract accessors (getters/setters) - group by name to merge pairs
const accessors: Map<
string,
{getter: ts.GetAccessorDeclaration | null; setter: ts.SetAccessorDeclaration | null}
> = new Map();
for (const member of node.members) {
if (ts.isGetAccessor(member) || ts.isSetAccessor(member)) {
const accessorName = ts.isIdentifier(member.name) ? member.name.text : member.name.getText();
if (!accessorName) continue;
// Skip private accessors - protected are part of the extension API
const modifiers = ts.getModifiers(member);
if (modifiers) {
const isPrivate = modifiers.some((m) => m.kind === ts.SyntaxKind.PrivateKeyword);
if (isPrivate) continue;
}
const existing = accessors.get(accessorName) ?? {getter: null, setter: null};
if (ts.isGetAccessor(member)) {
existing.getter = member;
} else {
existing.setter = member;
}
accessors.set(accessorName, existing);
}
}
// Create declarations for accessor pairs
for (const [accessorName, {getter, setter}] of accessors) {
const accessorDeclaration: MemberJsonBuild = {
name: accessorName,
kind: 'variable',
};
// Build modifiers: getter/setter indicators + other modifiers (static, etc.)
const accessorModifiers: Array<DeclarationModifier> = [];
if (getter) accessorModifiers.push('getter');
if (setter) accessorModifiers.push('setter');
// Extract other modifiers from getter (or setter if no getter)
const primaryAccessor = getter ?? setter!;
const otherModifiers = extractModifiers(ts.getModifiers(primaryAccessor));
accessorModifiers.push(...otherModifiers);
if (accessorModifiers.length > 0) {
accessorDeclaration.modifiers = accessorModifiers;
}
// Extract TSDoc - prefer getter's, fall back to setter's
const getterTsdoc = getter ? parseComment(getter, node.getSourceFile()) : undefined;
const setterTsdoc = setter ? parseComment(setter, node.getSourceFile()) : undefined;
const accessorTsdoc = getterTsdoc ?? setterTsdoc;
applyToDeclaration(accessorDeclaration, accessorTsdoc);
// Extract type signature from getter's return type
try {
if (getter) {
const getterSymbol = checker.getSymbolAtLocation(getter.name);
if (getterSymbol) {
const getterType = checker.getTypeOfSymbolAtLocation(getterSymbol, getter);
accessorDeclaration.typeSignature = checker.typeToString(getterType);
}
} else if (setter?.parameters.length) {
// Fall back to setter's parameter type if no getter
const param = setter.parameters[0]!;
if (param.type) {
accessorDeclaration.typeSignature = param.type.getText();
}
}
} catch (err) {
accessorDeclaration.partial = true;
const loc = getNodeLocation(primaryAccessor);
const className = node.name?.text ?? '<anonymous>';
diagnostics.push({
kind: 'class_member_failed',
file: loc.file,
line: loc.line,
column: loc.column,
message: `Failed to analyze accessor "${accessorName}" in class "${className}": ${err instanceof Error ? err.message : String(err)}`,
severity: 'warning',
className,
memberName: accessorName,
});
}
(declaration.members ??= []).push(accessorDeclaration);
}
};
{
"path": "typescript-extract-class.ts",
"declarations": [
{
"name": "extractClassInfo",
"kind": "function",
"docComment": "Extract class information with rich member metadata.",
"typeSignature": "(node: Node, checker: TypeChecker, declaration: DeclarationJsonBuild, diagnostics: ({ symbolName: string; file: string; message: string; severity: \"error\" | \"warning\"; kind: \"type_extraction_failed\"; line?: number | undefined; column?: number | undefined; } | ... 12 more ... | { ...; })[]): void",
"sourceLine": 40,
"mutates": {
"declaration": "adds extends, implements, genericParams, members"
},
"parameters": [
{
"name": "node",
"type": "Node",
"description": "the declaration AST node"
},
{
"name": "checker",
"type": "TypeChecker",
"description": "TypeScript type checker"
},
{
"name": "declaration",
"type": "DeclarationJsonBuild",
"description": "the declaration to populate"
},
{
"name": "diagnostics",
"type": "({ symbolName: string; file: string; message: string; severity: \"error\" | \"warning\"; kind: \"type_extraction_failed\"; line?: number | undefined; column?: number | undefined; } | { functionName: string; ... 5 more ...; column?: number | undefined; } | ... 11 more ... | { ...; })[]",
"description": "diagnostics collector for non-fatal issues"
}
],
"returnType": "void"
}
],
"moduleComment": "Per-declaration extractor for TypeScript classes.\n\n`extractClassInfo` mutates a `DeclarationJsonBuild` with rich metadata\nderived from the class declaration: heritage clauses, generic parameters,\nproperties, methods, constructors, and accessor pairs (getters/setters\nmerged by name). Called by `analyzeDeclaration` in `typescript-exports.ts`\nonce kind dispatch is settled.\n\n@see `typescript-extract-shared.ts` for shared helpers",
"dependencies": [
"declaration-build.ts",
"diagnostics.ts",
"tsdoc.ts",
"types.ts",
"typescript-extract-shared.ts"
],
"dependents": [
"typescript-exports.ts"
]
}