diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 4d895e1e..befe9d69 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -23,5 +23,5 @@ module.exports = { '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], }, - ignorePatterns: ['dist/', 'node_modules/', 'tests/', 'examples/', 'benchmarks/'] + ignorePatterns: ['dist/', 'node_modules/', 'tests/', 'examples/', 'benchmarks/', 'packages/*/bin/'] }; diff --git a/package-lock.json b/package-lock.json index 28b4a0d4..251a4ba1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -693,6 +693,7 @@ "version": "7.29.7", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -2216,6 +2217,7 @@ "version": "6.1.6", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", @@ -4431,7 +4433,6 @@ "version": "10.4.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4450,7 +4451,6 @@ "version": "5.2.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -4462,7 +4462,6 @@ "version": "27.5.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4475,8 +4474,7 @@ "node_modules/@testing-library/dom/node_modules/react-is": { "version": "17.0.2", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -4538,8 +4536,7 @@ "node_modules/@types/aria-query": { "version": "5.0.4", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4830,6 +4827,7 @@ "version": "8.62.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", @@ -5361,6 +5359,7 @@ "version": "8.16.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5800,6 +5799,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -6711,8 +6711,7 @@ "node_modules/dom-accessibility-api": { "version": "0.5.16", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/domexception": { "version": "4.0.0", @@ -7030,6 +7029,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -7102,6 +7102,7 @@ "version": "8.57.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8733,6 +8734,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -10001,7 +10003,6 @@ "version": "1.5.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10114,6 +10115,7 @@ "version": "12.0.2", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -12424,6 +12426,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14302,13 +14305,13 @@ "node_modules/scheduler": { "version": "0.27.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semantic-release": { "version": "23.1.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^12.0.0", "@semantic-release/error": "^4.0.0", @@ -14785,6 +14788,7 @@ "version": "11.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "bytes-iec": "^3.1.1", "chokidar": "^4.0.3", @@ -15388,6 +15392,7 @@ "version": "4.0.4", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16235,6 +16240,7 @@ "version": "5.9.3", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/packages/codegen/src/generator.ts b/packages/codegen/src/generator.ts index e19303c4..f6655b7f 100644 --- a/packages/codegen/src/generator.ts +++ b/packages/codegen/src/generator.ts @@ -1,16 +1,18 @@ -import { ContractSpec, SpecFunction, SpecStruct, SpecEnum } from "./wasmParser"; +import { ContractSpec, SpecFunction, SpecStruct, SpecEnum } from './wasmParser'; /** * Converts a snake_case or kebab-case identifier to PascalCase. */ -function toPascalCase(s: string): string { - return s.replace(/[-_](.)/g, (_, c) => c.toUpperCase()).replace(/^(.)/, (_, c) => c.toUpperCase()); +export function toPascalCase(s: string): string { + return s + .replace(/[-_](.)/g, (_, c: string) => c.toUpperCase()) + .replace(/^(.)/, (_, c: string) => c.toUpperCase()); } /** * Converts a snake_case identifier to camelCase. */ -function toCamelCase(s: string): string { +export function toCamelCase(s: string): string { const pascal = toPascalCase(s); return pascal.charAt(0).toLowerCase() + pascal.slice(1); } @@ -21,11 +23,11 @@ function toCamelCase(s: string): string { * Returns the expression that encodes a TypeScript value `varName` of the * given scSpecType into an `xdr.ScVal` using BaseContract helpers. */ -function encodeExpr(varName: string, scSpecType: string): string { - if (scSpecType === "address") return `this.encodeAddress(${varName})`; - if (scSpecType === "i128") return `this.encodeI128(${varName})`; - if (scSpecType === "u128") return `this.encodeU128(${varName})`; - if (scSpecType.startsWith("udt:")) return `this.encodeUdt(${varName})`; +export function encodeExpr(varName: string, scSpecType: string): string { + if (scSpecType === 'address') return `this.encodeAddress(${varName})`; + if (scSpecType === 'i128') return `this.encodeI128(${varName})`; + if (scSpecType === 'u128') return `this.encodeU128(${varName})`; + if (scSpecType.startsWith('udt:')) return `this.encodeUdt(${varName})`; // For all other primitives, fall back to nativeToScVal return `nativeToScVal(${varName})`; } @@ -34,65 +36,66 @@ function encodeExpr(varName: string, scSpecType: string): string { * Returns the expression that decodes an `xdr.ScVal` result into the * TypeScript type described by scSpecType. */ -function decodeExpr(resultExpr: string, scSpecType: string): string { - if (scSpecType === "bool") return `this.decodeBool(${resultExpr})`; - if (scSpecType === "string" || scSpecType === "symbol") return `this.decodeString(${resultExpr})`; - if (scSpecType === "i128") return `this.decodeI128(${resultExpr})`; - if (scSpecType === "u128") return `this.decodeU128(${resultExpr})`; - if (scSpecType === "u64") return `this.decodeU64(${resultExpr})`; - if (scSpecType === "void") return "undefined"; +export function decodeExpr(resultExpr: string, scSpecType: string): string { + if (scSpecType === 'bool') return `this.decodeBool(${resultExpr})`; + if (scSpecType === 'string' || scSpecType === 'symbol') return `this.decodeString(${resultExpr})`; + if (scSpecType === 'i128') return `this.decodeI128(${resultExpr})`; + if (scSpecType === 'u128') return `this.decodeU128(${resultExpr})`; + if (scSpecType === 'u64') return `this.decodeU64(${resultExpr})`; + if (scSpecType === 'void') return 'undefined'; // For UDTs and unknowns, return the raw ScVal return resultExpr; } // ─── Interface generation ──────────────────────────────────────────────────── -function generateArgsInterface(fn: SpecFunction): string { - if (fn.inputs.length === 0) return ""; +export function generateArgsInterface(fn: SpecFunction): string { + if (fn.inputs.length === 0) return ''; const ifaceName = `${toPascalCase(fn.name)}Args`; - const fields = fn.inputs.map(p => ` ${p.name}: ${p.type};`).join("\n"); + const fields = fn.inputs.map((p) => ` ${p.name}: ${p.type};`).join('\n'); return `export interface ${ifaceName} {\n${fields}\n}\n`; } -function generateStructInterface(s: SpecStruct): string { - const fields = s.fields.map(f => ` ${f.name}: ${f.type};`).join("\n"); - const doc = s.doc ? `/** ${s.doc} */\n` : ""; +export function generateStructInterface(s: SpecStruct): string { + const fields = s.fields.map((f) => ` ${f.name}: ${f.type};`).join('\n'); + const doc = s.doc ? `/** ${s.doc} */\n` : ''; return `${doc}export interface ${s.name} {\n${fields}\n}\n`; } -function generateEnumType(e: SpecEnum): string { - const cases = e.cases.map(c => ` ${c.name} = ${c.value},`).join("\n"); - const doc = e.doc ? `/** ${e.doc} */\n` : ""; +export function generateEnumType(e: SpecEnum): string { + const cases = e.cases.map((c) => ` ${c.name} = ${String(c.value)},`).join('\n'); + const doc = e.doc ? `/** ${e.doc} */\n` : ''; return `${doc}export enum ${e.name} {\n${cases}\n}\n`; } // ─── Method generation ─────────────────────────────────────────────────────── -function generateMethod(fn: SpecFunction): string { +export function generateMethod(fn: SpecFunction): string { const methodName = toCamelCase(fn.name); const hasArgs = fn.inputs.length > 0; const argsIfaceName = `${toPascalCase(fn.name)}Args`; // Return type const output = fn.outputs[0]; - const returnTs = output ? output.type : "void"; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const returnTs = output ? output.type : 'void'; const promiseReturn = fn.readonly ? `Promise<${returnTs}>` : `Promise`; // Parameter signature - const paramSig = hasArgs ? `args: ${argsIfaceName}` : ""; + const paramSig = hasArgs ? `args: ${argsIfaceName}` : ''; // Build args array for invoke/query - const argsArray = fn.inputs.length > 0 - ? `[\n ${fn.inputs.map(p => `${encodeExpr(`args.${p.name}`, p.scSpecType)}`).join(",\n ")}\n ]` - : "[]"; + const argsArray = + fn.inputs.length > 0 + ? `[\n ${fn.inputs.map((p) => encodeExpr(`args.${p.name}`, p.scSpecType)).join(',\n ')}\n ]` + : '[]'; // Doc comment - const doc = fn.doc - ? ` /**\n * ${fn.doc.replace(/\n/g, "\n * ")}\n */\n` - : ""; + const doc = fn.doc ? ` /**\n * ${fn.doc.replace(/\n/g, '\n * ')}\n */\n` : ''; if (fn.readonly) { - const decode = output ? decodeExpr("_result", output.scSpecType) : "undefined"; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const decode = output ? decodeExpr('_result', output.scSpecType) : 'undefined'; return ( `${doc} async ${methodName}(${paramSig}): ${promiseReturn} {\n` + ` const _result = await this.query("${fn.name}", ${argsArray});\n` + @@ -108,57 +111,119 @@ function generateMethod(fn: SpecFunction): string { } } -// ─── Main generator ────────────────────────────────────────────────────────── +/** + * Template context passed to custom template functions. + */ +export interface TemplateContext { + spec: ContractSpec; + className: string; + coreImport: string; + helpers: { + toPascalCase: typeof toPascalCase; + toCamelCase: typeof toCamelCase; + encodeExpr: typeof encodeExpr; + decodeExpr: typeof decodeExpr; + generateArgsInterface: typeof generateArgsInterface; + generateStructInterface: typeof generateStructInterface; + generateEnumType: typeof generateEnumType; + generateMethod: typeof generateMethod; + }; +} /** - * Generates a complete TypeScript source file for a contract class that - * extends BaseContract, based on the parsed ContractSpec. - * - * @param spec - Parsed contract specification from wasmParser - * @param className - Name for the generated class (e.g. "TokenContract") - * @param coreImport - Import path for @axionvera/core (default: "@axionvera/core") - * @returns - TypeScript source code as a string + * Custom template function type. */ -export function generateContractClass( - spec: ContractSpec, - className: string, - coreImport = "@axionvera/core" -): string { +export type TemplateFunction = (context: TemplateContext) => string; + +// ─── Default template ───────────────────────────────────────────────────────── + +const defaultTemplate: TemplateFunction = (context) => { + const { spec, className, coreImport, helpers } = context; const lines: string[] = []; - // ── Header ── + // ─── Header ───────────────────────────────────────────────────────────────── lines.push(`// Auto-generated by axionvera-cli codegen. DO NOT EDIT.`); lines.push(`// Re-run \`axionvera-cli codegen \` to regenerate.\n`); lines.push(`import { nativeToScVal } from "@stellar/stellar-sdk";`); - lines.push(`import { BaseContract, BaseContractConfig } from "${coreImport}/contracts/BaseContract";\n`); + lines.push( + `import { BaseContract, BaseContractConfig } from "${coreImport}/contracts/BaseContract";\n` + ); - // ── UDT interfaces / enums ── + // ─── UDT interfaces / enums ───────────────────────────────────────────────── for (const s of spec.structs) { - lines.push(generateStructInterface(s)); + lines.push(helpers.generateStructInterface(s)); } for (const e of spec.enums) { - lines.push(generateEnumType(e)); + lines.push(helpers.generateEnumType(e)); } - // ── Args interfaces ── + // ─── Args interfaces ──────────────────────────────────────────────────────── for (const fn of spec.functions) { - const iface = generateArgsInterface(fn); + const iface = helpers.generateArgsInterface(fn); if (iface) lines.push(iface); } - // ── Class ── + // ─── Class ────────────────────────────────────────────────────────────────── lines.push(`export class ${className} extends BaseContract {`); lines.push(` constructor(config: BaseContractConfig) {`); lines.push(` super(config);`); lines.push(` }\n`); for (const fn of spec.functions) { - lines.push(generateMethod(fn)); + lines.push(helpers.generateMethod(fn)); } lines.push(`}`); lines.push(``); - return lines.join("\n"); + return lines.join('\n'); +}; + +// ─── Main generator ────────────────────────────────────────────────────────── + +/** + * Generates a complete TypeScript source file for a contract class that + * extends BaseContract, based on the parsed ContractSpec. + * + * @param spec - Parsed contract specification from wasmParser + * @param className - Name for the generated class (e.g. "TokenContract") + * @param coreImportOrTemplate - Import path for @axionvera/core (default: "@axionvera/core") or custom template function + * @param template - Custom template function (optional, if third param is coreImport) + * @returns - TypeScript source code as a string + */ +export function generateContractClass( + spec: ContractSpec, + className: string, + coreImportOrTemplate: string | TemplateFunction = '@axionvera/core', + template?: TemplateFunction +): string { + let coreImport: string; + let finalTemplate: TemplateFunction; + + if (typeof coreImportOrTemplate === 'function') { + coreImport = '@axionvera/core'; + finalTemplate = coreImportOrTemplate; + } else { + coreImport = coreImportOrTemplate; + finalTemplate = template ?? defaultTemplate; + } + + const context: TemplateContext = { + spec, + className, + coreImport, + helpers: { + toPascalCase, + toCamelCase, + encodeExpr, + decodeExpr, + generateArgsInterface, + generateStructInterface, + generateEnumType, + generateMethod, + }, + }; + + return finalTemplate(context); } diff --git a/packages/codegen/src/index.ts b/packages/codegen/src/index.ts index 2ab20a46..311a16f2 100644 --- a/packages/codegen/src/index.ts +++ b/packages/codegen/src/index.ts @@ -1,3 +1,22 @@ export { parseWasm } from './wasmParser'; export { generateContractClass } from './generator'; -export type { ContractSpec, SpecFunction, SpecParam, SpecStruct, SpecEnum, SpecEnumCase, SpecStructField } from './wasmParser'; +export type { + ContractSpec, + SpecFunction, + SpecParam, + SpecStruct, + SpecEnum, + SpecEnumCase, + SpecStructField, +} from './wasmParser'; +export type { TemplateContext, TemplateFunction } from './generator'; +export { + toPascalCase, + toCamelCase, + encodeExpr, + decodeExpr, + generateArgsInterface, + generateStructInterface, + generateEnumType, + generateMethod, +} from './generator'; diff --git a/packages/core/bin/commands/codegen.js b/packages/core/bin/commands/codegen.js index 519f9dbc..8e3ed185 100644 --- a/packages/core/bin/commands/codegen.js +++ b/packages/core/bin/commands/codegen.js @@ -109,27 +109,23 @@ function run(argv) { console.log(`Generated: ${outFile}`); } -// Loads the codegen implementation from dist (production) or src (ts-node). +// Loads the codegen implementation from @axionvera/codegen package. function requireCodegen() { - const distParser = path.join(PKG_ROOT, "dist/codegen/wasmParser.js"); - const distGenerator = path.join(PKG_ROOT, "dist/codegen/generator.js"); - if (fs.existsSync(distParser) && fs.existsSync(distGenerator)) { - return { - parseWasm: require(distParser).parseWasm, - generateContractClass: require(distGenerator).generateContractClass, - }; - } - const srcParser = path.join(PKG_ROOT, "src/codegen/wasmParser.ts"); - if (fs.existsSync(srcParser)) { - try { - require("ts-node/register"); - } catch (_) {} - return { - parseWasm: require(srcParser).parseWasm, - generateContractClass: require(path.join(PKG_ROOT, "src/codegen/generator.ts")).generateContractClass, - }; + // Try to require the built @axionvera/codegen package first + try { + return require("@axionvera/codegen"); + } catch (err) { + // If that fails, try to require it from the source (for development) + const codegenPkgPath = path.join(PKG_ROOT, "..", "codegen"); + const srcIndex = path.join(codegenPkgPath, "src/index.ts"); + if (fs.existsSync(srcIndex)) { + try { + require("ts-node/register"); + } catch (_) {} + return require(srcIndex); + } + throw new Error(`Could not load @axionvera/codegen: ${err.message}. Ensure dependencies are installed.`); } - throw new Error("Could not locate codegen modules. Run `npm run build` in packages/core first."); } module.exports = { run, HELP };