Files
AFFiNE-Mirror/packages/frontend/apps/electron/scripts/ipc-generator/events-parser.ts
2025-06-18 15:34:09 +08:00

278 lines
9.9 KiB
TypeScript

import { Node, Project, PropertyDeclaration } from 'ts-morph';
import { type CollectedEventsMap, type ParsedEventInfo } from './types';
import { determineEntry } from './utils';
export const IpcEventDecoratorName = 'IpcEvent';
/**
* Parses the @IpcEvent decorator and extracts relevant information
*/
export function parseIpcEventDecorator(
propertyDeclaration: PropertyDeclaration
): Omit<ParsedEventInfo, 'description'> | { error: string } {
const decorator = propertyDeclaration
.getDecorators()
.find(d => d.getName() === IpcEventDecoratorName);
if (!decorator) return { error: 'Decorator not found' };
const args = decorator.getArguments();
const sourceFile = propertyDeclaration.getSourceFile();
const propertyNameInCode = propertyDeclaration.getName();
if (args.length === 0) {
return {
error: `@${IpcEventDecoratorName} on ${propertyNameInCode} in ${sourceFile.getFilePath()} is missing arguments.`,
};
}
const optionsArg = args[0];
if (!Node.isObjectLiteralExpression(optionsArg)) {
return {
error: `@${IpcEventDecoratorName} on ${propertyNameInCode} in ${sourceFile.getFilePath()} requires an object argument.`,
};
}
let scopeValue: string | undefined;
const scopeProperty = optionsArg.getProperty('scope');
if (scopeProperty && Node.isPropertyAssignment(scopeProperty)) {
const initializer = scopeProperty.getInitializer();
if (initializer) {
if (Node.isStringLiteral(initializer))
scopeValue = initializer.getLiteralValue();
else if (Node.isPropertyAccessExpression(initializer)) {
const type = initializer.getType();
if (type.isStringLiteral())
scopeValue = type.getLiteralValue() as string;
else scopeValue = initializer.getNameNode().getText();
}
}
}
if (!scopeValue)
return {
error: `@${IpcEventDecoratorName} on ${propertyNameInCode}: missing valid 'scope'.`,
};
let declaredName: string | undefined;
const nameProperty = optionsArg.getProperty('name');
if (nameProperty && Node.isPropertyAssignment(nameProperty)) {
const initializer = nameProperty.getInitializer();
if (initializer && Node.isStringLiteral(initializer))
declaredName = initializer.getLiteralValue();
else if (initializer)
return {
error: `@${IpcEventDecoratorName} on ${propertyNameInCode}: 'name' must be a string literal.`,
};
}
const eventName = declaredName ?? propertyNameInCode.replace(/\$$/, '');
const ipcChannel = `${scopeValue}:${eventName}`;
let payloadType = 'any[]'; // Default
const propertyTypeNode = propertyDeclaration.getTypeNode();
const propertyType = propertyDeclaration.getType(); // Get the full Type object
// Attempt 1: Regex on TypeNode text (faster, good for common cases)
if (propertyTypeNode) {
const typeNodeText = propertyTypeNode.getText();
// Consolidated regex for known stream types including Observable
const knownStreamTypesRegex =
/(?:BehaviorSubject|ReplaySubject|Subject|Observable|EventEmitter)<([^>]+)>/;
const typeMatch = typeNodeText.match(knownStreamTypesRegex);
if (typeMatch && typeMatch[1]) {
payloadType = typeMatch[1].trim();
}
}
// Attempt 2: If regex failed or resulted in default, try more robust Type object inspection
if (payloadType === 'any[]') {
// Only if not found by Attempt 1 (explicit type annotation)
const typesToInspect: import('ts-morph').Type[] = [propertyType];
if (propertyType.isIntersection()) {
typesToInspect.push(...propertyType.getIntersectionTypes());
}
// Consider base types if the primary type itself is not a directly recognized Observable symbol
// This helps with classes extending Observable<T>
const primarySymbolName = propertyType.getSymbol()?.getName();
const isPrimaryRecognizedObservable = [
'Subject',
'BehaviorSubject',
'ReplaySubject',
'EventEmitter',
'Observable',
].includes(primarySymbolName || '');
if (!isPrimaryRecognizedObservable && propertyType.isClassOrInterface()) {
typesToInspect.push(...propertyType.getBaseTypes());
}
for (const typeToInspect of typesToInspect) {
// Ensure we are dealing with a type that can have generics and a symbol (class/interface)
// isAnonymous handles cases within intersections that might not be directly isClassOrInterface
if (
!typeToInspect.isClassOrInterface() &&
!typeToInspect.isAnonymous() &&
!typeToInspect.isObject()
)
continue;
const typeName = typeToInspect.getSymbol()?.getName();
if (
typeName === 'Subject' ||
typeName === 'BehaviorSubject' ||
typeName === 'ReplaySubject' ||
typeName === 'EventEmitter' ||
typeName === 'Observable'
) {
const typeArguments = typeToInspect.getTypeArguments();
if (typeArguments.length > 0) {
const argText = typeArguments[0].getText(sourceFile).trim();
// Prioritize more specific types over 'any' or 'unknown' if multiple paths yield a type.
if (
payloadType === 'any[]' ||
((payloadType === 'any' || payloadType === 'unknown') &&
argText !== 'any' &&
argText !== 'unknown')
) {
payloadType = argText;
}
// If we found a concrete type (not any/unknown/any[]), we can stop searching.
if (
payloadType !== 'any' &&
payloadType !== 'unknown' &&
payloadType !== 'any[]'
) {
break; // Found a good type from typesToInspect loop
}
}
// If typeArguments is empty here, it implies Observable (or Subject, etc.) without a generic.
// The later fallback `if (payloadType === 'any[]') payloadType = 'any'` will handle this.
}
}
// If, after checking all typesToInspect, payloadType is still 'any[]' or a non-specific type,
// and we found a specific type from the primary propertyType earlier (even if it was 'any'),
// we might need to ensure the most specific one found is kept.
// However, the current logic of only updating if more specific should handle this.
}
// Attempt 3: Look at initializer expression (e.g., new Subject<void>()) if still default
if (payloadType === 'any[]') {
const initializer = propertyDeclaration.getInitializer();
if (initializer && Node.isNewExpression(initializer)) {
const typeArgs = initializer.getType().getTypeArguments();
if (typeArgs.length > 0) {
payloadType = typeArgs[0].getText(sourceFile).trim();
}
}
}
// If still default any[] but we have detected Subject without generic, treat as any not array
if (payloadType === 'any[]') {
payloadType = 'any';
}
// Final cleanup for void
if (payloadType.toLowerCase() === 'void') {
payloadType = ''; // Represent void as empty params for callback
}
return {
scope: scopeValue,
eventName,
ipcChannel,
payloadType,
originalPropertyName: propertyNameInCode,
entry: determineEntry(sourceFile.getFilePath()),
filePath: sourceFile.getFilePath(),
};
}
/**
* Parses all IPC events in the project and collects their information
*/
export function parseIpcEvents(project: Project): {
events: CollectedEventsMap;
} {
const collectedEvents: CollectedEventsMap = new Map();
project.getSourceFiles().forEach(sourceFile => {
sourceFile.getClasses().forEach(classDeclaration => {
classDeclaration.getProperties().forEach(propertyDeclaration => {
const decorator = propertyDeclaration
.getDecorators()
.find(d => d.getName() === IpcEventDecoratorName);
if (!decorator) return;
const parsedEventInfo = parseIpcEventDecorator(propertyDeclaration);
if ('error' in parsedEventInfo) {
if (parsedEventInfo.error !== 'Decorator not found')
console.error(`[Event ERR] ${parsedEventInfo.error}`);
return;
}
const {
scope,
eventName,
payloadType,
entry,
filePath,
originalPropertyName,
} = parsedEventInfo;
// derive modulePath and className here
const absPath = filePath as string;
const idx = absPath.lastIndexOf('/src/');
let modulePath = absPath;
if (idx !== -1) {
modulePath =
'@affine/electron/' + absPath.substring(idx + '/src/'.length);
}
modulePath = modulePath.replace(/\.[tj]sx?$/, '');
const clsName =
propertyDeclaration.getParent()?.getName?.() || 'UnknownClass';
const propName = originalPropertyName;
const description = propertyDeclaration
.getJsDocs()
.map(doc => doc.getDescription().trim())
.filter(Boolean)
.join('\n');
if (!collectedEvents.has(scope)) collectedEvents.set(scope, []);
const eventScopeMethods = collectedEvents.get(scope);
if (eventScopeMethods) {
const existingEvent = eventScopeMethods.find(
event => event.eventName === eventName
);
if (existingEvent) {
throw new Error(
`[Event ERR] Duplicate event found for scope '${scope}' and eventName '${eventName}'.\n` +
` Original: ${existingEvent.filePath} (${existingEvent.className}.${existingEvent.propertyName})\n` +
` Duplicate: ${filePath as string} (${clsName}.${propName})`
);
}
eventScopeMethods.push({
eventName,
payloadType,
modulePath,
className: clsName,
propertyName: propName,
description,
entry,
filePath: filePath as string,
});
} else {
console.error(
`[CRITICAL] Failed to retrieve event methods array for scope: ${scope}`
);
}
});
});
});
return {
events: collectedEvents,
};
}