import fs from 'node:fs/promises'; import path from 'node:path'; import ts from 'typescript'; import { normalizePath } from '../utils/path-utils.js'; import { extractPlaceholderKeys } from '../utils/placeholders.js'; import type { RepositorySourceContext } from './ts-project.js'; export interface HelperBinding { exportedName: string; returnedProperty?: string; alias?: string; aliasExpr?: string; destinationExpr?: string; servicePathExpr?: string; isDynamic: boolean; placeholders: string[]; helperChain?: Array>; sourceFile: string; sourceLine: number; } export interface ImportBinding { localName: string; exportedName: string; sourceFile?: string; } export interface ClassHelperReturn { className: string; helperName: string; propertyName: string; variableName: string; fact: Omit; sourceLine: number; } export function lineOf(sf: ts.SourceFile, node: ts.Node): number { return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1; } function stringValue(node: ts.Expression | undefined): string | undefined { if (!node) return undefined; if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; if (ts.isTemplateExpression(node)) return node.getText().replace(/^`|`$/g, ''); return node.getText(); } function placeholders(value?: string): string[] { return extractPlaceholderKeys(value); } type ConnectFact = Omit< HelperBinding, 'exportedName' | 'sourceFile' | 'sourceLine' >; function connectMethod( call: ts.CallExpression, ): 'to' | 'messaging' | undefined { const expression = call.expression; if (!ts.isPropertyAccessExpression(expression) || !['to', 'messaging'].includes(expression.name.text)) return undefined; const receiver = expression.expression; if (!ts.isPropertyAccessExpression(receiver) || receiver.name.text !== 'connect' || receiver.expression.getText() !== 'cds') return undefined; return expression.name.text === 'messaging' ? 'messaging' : 'to'; } function connectObject( first: ts.Expression, second: ts.Expression | undefined, ): ts.ObjectLiteralExpression | undefined { if (ts.isObjectLiteralExpression(first)) return first; return second && ts.isObjectLiteralExpression(second) ? second : undefined; } function connectNames( method: 'to' | 'messaging', first: ts.Expression, ): { alias?: string; aliasExpr?: string } { if (ts.isStringLiteralLike(first) || ts.isNoSubstitutionTemplateLiteral(first)) return { alias: first.text }; if (ts.isObjectLiteralExpression(first)) return method === 'messaging' ? { alias: 'messaging' } : {}; return { aliasExpr: stringValue(first) }; } function literalConnectFact( first: ts.Expression, object: ts.ObjectLiteralExpression | undefined, ): ConnectFact | undefined { if (object || (!ts.isStringLiteralLike(first) && !ts.isNoSubstitutionTemplateLiteral(first))) return undefined; return { alias: first.text, isDynamic: false, placeholders: [] }; } function dynamicConnectFact( placeholdersFound: readonly string[], expressions: { destinationExpr?: string; servicePathExpr?: string }, ): boolean { return placeholdersFound.length > 0 || (!expressions.destinationExpr && !expressions.servicePathExpr); } export function connectFactFromCall( call: ts.CallExpression, ): ConnectFact | undefined { const method = connectMethod(call); if (!method) return undefined; const first = call.arguments[0]; if (!first) return method === 'messaging' ? { alias: 'messaging', isDynamic: false, placeholders: [] } : undefined; const second = call.arguments[1]; const objectArg = connectObject(first, second); const names = connectNames(method, first); const literal = literalConnectFact(first, objectArg); if (literal) return literal; if (!objectArg && names.aliasExpr) return { aliasExpr: names.aliasExpr, isDynamic: true, placeholders: placeholders(names.aliasExpr), }; const expressions = objectArg ? objectExpressions(objectArg) : {}; const ph = [ ...placeholders(names.aliasExpr ?? names.alias), ...placeholders(expressions.destinationExpr), ...placeholders(expressions.servicePathExpr), ]; return { ...names, ...expressions, isDynamic: dynamicConnectFact(ph, expressions), placeholders: ph, }; } function objectExpressions(objectArg: ts.ObjectLiteralExpression): { destinationExpr?: string; servicePathExpr?: string } { const out: { destinationExpr?: string; servicePathExpr?: string } = {}; function visitObject(obj: ts.ObjectLiteralExpression): void { for (const prop of obj.properties) { if (!ts.isPropertyAssignment(prop)) continue; const name = ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name) ? prop.name.text : undefined; if (name === 'destination') out.destinationExpr = stringValue(prop.initializer); if (name === 'path' || name === 'servicePath') out.servicePathExpr = stringValue(prop.initializer); if (ts.isObjectLiteralExpression(prop.initializer)) visitObject(prop.initializer); } } visitObject(objectArg); return out; } export function unwrapCall(expr: ts.Expression): ts.CallExpression | undefined { if (ts.isAwaitExpression(expr)) return unwrapCall(expr.expression); if (ts.isParenthesizedExpression(expr)) return unwrapCall(expr.expression); if (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr)) return unwrapCall(expr.expression); if (ts.isTypeAssertionExpression(expr)) return unwrapCall(expr.expression); if (ts.isCallExpression(expr)) return expr; return undefined; } export function unwrapIdentityExpression(expr: ts.Expression): ts.Expression { if (ts.isAwaitExpression(expr)) return unwrapIdentityExpression(expr.expression); if (ts.isParenthesizedExpression(expr)) return unwrapIdentityExpression(expr.expression); if (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr)) return unwrapIdentityExpression(expr.expression); if (ts.isTypeAssertionExpression(expr)) return unwrapIdentityExpression(expr.expression); return expr; } export function transactionReceiverName(expr: ts.Expression): string | undefined { const call = unwrapCall(expr); if (call && ts.isPropertyAccessExpression(call.expression) && ['tx', 'transaction'].includes(call.expression.name.text) && ts.isIdentifier(call.expression.expression)) return call.expression.expression.text; const unwrapped = unwrapIdentityExpression(expr); if (!ts.isConditionalExpression(unwrapped)) return undefined; const left = transactionReceiverName(unwrapped.whenTrue); const right = transactionReceiverName(unwrapped.whenFalse); return left && left === right ? left : undefined; } export function findConnectInExpression(expr: ts.Expression): Omit | undefined { const direct = unwrapCall(expr); if (direct) { const fact = connectFactFromCall(direct); if (fact) return fact; } let found: Omit | undefined; function visit(node: ts.Node): void { if (found) return; if (ts.isCallExpression(node)) found = connectFactFromCall(node); if (!found) ts.forEachChild(node, visit); } visit(expr); return found; } export async function readSource( abs: string, context?: RepositorySourceContext, filePath?: string, ): Promise { const snapshot = filePath ? context?.get(filePath) : undefined; if (snapshot) return snapshot.sourceFile(); try { const text = await fs.readFile(abs, 'utf8'); return ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); } catch { return undefined; } } async function resolveImport( repoPath: string, fromFile: string, spec: string, context?: RepositorySourceContext, ): Promise { if (!spec.startsWith('.')) return undefined; const rawBase = path.resolve(repoPath, path.dirname(fromFile), spec); const parsed = path.parse(rawBase); const base = ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'].includes(parsed.ext) ? path.join(parsed.dir, parsed.name) : rawBase; for (const candidate of [base, `${base}.ts`, `${base}.js`, path.join(base, 'index.ts'), path.join(base, 'index.js')]) { const relative = normalizePath(path.relative(repoPath, candidate)); if (context?.get(relative)) return relative; const stat = await fs.stat(candidate).catch(() => undefined); if (stat?.isFile()) return relative; } return undefined; } export async function importsFor( repoPath: string, filePath: string, sf: ts.SourceFile, context?: RepositorySourceContext, ): Promise { const imports: ImportBinding[] = []; for (const stmt of sf.statements) { if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteralLike(stmt.moduleSpecifier)) continue; const sourceFile = await resolveImport( repoPath, filePath, stmt.moduleSpecifier.text, context, ); const clause = stmt.importClause; if (!clause) continue; if (clause.name) imports.push({ localName: clause.name.text, exportedName: 'default', sourceFile }); const bindings = clause.namedBindings; if (bindings && ts.isNamedImports(bindings)) for (const el of bindings.elements) imports.push({ localName: el.name.text, exportedName: el.propertyName?.text ?? el.name.text, sourceFile }); } return imports; }