/** * cli:scaffold-extension-search — generate.ts * * Renders the `search.Entity<…>(…)` declarations and patches the client's * DependencyInjection.cs idempotently: * - markers present → replace ONLY the content between the * `<<< EXTENSION-SEARCH-DI BEGIN/END >>>` markers (re-runs are clean); * - markers absent → insert a full `services.AddExtensionSearch(…)` * wrapper before the last `return services;`, adding the one required * `using` so it compiles. * * Entity types are emitted FULLY-QUALIFIED (as provided in the spec) so the host * file needs no extra entity `using` and the `Task` collision is avoided. */ import { BEGIN_MARKER, END_MARKER, SEARCH_NAMESPACE, type ExtensionSearchSpec, type SearchEntity } from './types.js'; const IND = ' '; // 12 spaces — markers + first line of each entity (inside the lambda body) const CONT = ' '; // 18 spaces — fluent continuation function escapeString(s: string): string { return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } /** Render one `search.Entity<…>(…)….;` statement. */ export function renderEntity(e: SearchEntity): string { const lines: string[] = []; lines.push(`${IND}search.Entity<${e.entityName}>("${escapeString(e.categoryKey)}", "${escapeString(e.label)}", "${escapeString(e.icon)}")`); lines.push(`${CONT}.RequirePermission("${escapeString(e.permission)}")`); lines.push(`${CONT}.RouteTo(e => $"${e.route.replace(/\{id\}/g, '{e.Id}')}")`); if (e.tenantScoped) lines.push(`${CONT}.TenantScoped()`); if (e.rowScope) { lines.push(`${CONT}.RestrictTo(scope => scope.Has("${escapeString(e.rowScope.bypassPermission)}")`); lines.push(`${CONT} ? null`); lines.push(`${CONT} : e => e.${e.rowScope.ownerProperty} == scope.UserId)`); } if (e.order !== 100) lines.push(`${CONT}.Order(${e.order})`); lines[lines.length - 1] += ';'; return lines.join('\n'); } /** The content that lives BETWEEN the markers (markers included). */ export function renderInnerBlock(spec: ExtensionSearchSpec): string { const body = spec.entities.map(renderEntity).join('\n\n'); return `${IND}${BEGIN_MARKER}\n${body}\n${IND}${END_MARKER}`; } /** The full `services.AddExtensionSearch(search => { … });` wrapper (used when no markers exist yet). */ export function renderFullBlock(spec: ExtensionSearchSpec): string { return [ ` services.AddExtensionSearch<${spec.contextType}>(search =>`, ` {`, renderInnerBlock(spec), ` });`, ].join('\n'); } /** Insert `using {ns};` after the last using directive when it is missing. */ export function ensureUsing(source: string, ns: string): string { const present = new RegExp(`^using\\s+${ns.replace(/\./g, '\\.')}\\s*;`, 'm'); if (present.test(source)) return source; const usingRe = /^using [^\n;]+;[ \t]*$/gm; let last: RegExpExecArray | null = null; let m: RegExpExecArray | null; while ((m = usingRe.exec(source)) !== null) last = m; if (!last) return source; // no usings to anchor to — caller falls back to fully-qualified call if needed const insertAt = last.index + last[0].length; return source.slice(0, insertAt) + `\nusing ${ns};` + source.slice(insertAt); } /** * Idempotently patch the DI source. Returns the new source, or `null` when no * change was needed (markers already up to date) or no insertion point was found. */ export function patchExtensionSearch(source: string, spec: ExtensionSearchSpec): string | null { const beginIdx = source.indexOf(BEGIN_MARKER); const endIdx = source.indexOf(END_MARKER); // 1. Markers exist → replace the whole BEGIN..END region (the wrapper already // carries the required `using`, so nothing else to touch). if (beginIdx >= 0 && endIdx >= 0 && endIdx > beginIdx) { const lineStart = source.lastIndexOf('\n', beginIdx) + 1; const lineEnd = source.indexOf('\n', endIdx); const before = source.slice(0, lineStart); const after = lineEnd >= 0 ? source.slice(lineEnd + 1) : ''; const next = before + renderInnerBlock(spec) + (lineEnd >= 0 ? '\n' : '') + after; return next === source ? null : next; } // 2. No markers → insert the full wrapper before the last `return services;`, // ensuring the one required using is present. const src = ensureUsing(source, SEARCH_NAMESPACE); const fullBlock = renderFullBlock(spec); const returnRe = /\n[ \t]*return services;[ \t]*\n/g; let last: RegExpExecArray | null = null; let m: RegExpExecArray | null; while ((m = returnRe.exec(src)) !== null) last = m; if (last) { return src.slice(0, last.index) + '\n' + fullBlock + src.slice(last.index); } // 3. Last resort — before the file's final brace. const lastBrace = src.lastIndexOf('}'); if (lastBrace >= 0) return src.slice(0, lastBrace) + fullBlock + '\n' + src.slice(lastBrace); return null; }