import { execSync } from 'child_process' import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' import { kebabCase, startCase } from 'lodash-es' import { commandDefines, CommandSchema, OptionSchema } from './commands.js' const __filename = fileURLToPath(import.meta.url) const defFolder = path.join(__filename, '../../src/def') const docsTargetFolder = path.join(__filename, '../../docs') function PascalCase(str: string) { return startCase(str).replace(/\s/g, '') } /** * convert command definition to command options interface */ function generateOptionsDef(command: CommandSchema) { const optionsName = `${PascalCase(command.name)}Options` const optLines: string[] = [] optLines.push('/**') optLines.push(` * ${command.description}`) optLines.push(' */') optLines.push(`export interface ${optionsName} {`) command.args.forEach((arg) => { optLines.push(' /**') optLines.push(` * ${arg.description}`) optLines.push(' */') optLines.push(` ${arg.name}${arg.required ? '' : '?'}: ${arg.type}`) }) command.options.forEach((opt) => { optLines.push(' /**') optLines.push(` * ${opt.description}`) if (typeof opt.default !== 'undefined') { optLines.push(' *') optLines.push(` * @default ${opt.default}`) } optLines.push(' */') optLines.push(` ${opt.name}${opt.required ? '' : '?'}: ${opt.type}`) }) optLines.push('}\n') if (command.options.some((opt) => typeof opt.default !== 'undefined')) { optLines.push( `export function applyDefault${optionsName}(options: ${optionsName}) {`, ) optLines.push(` return {`) command.options.forEach((opt) => { if (typeof opt.default !== 'undefined') { optLines.push(` ${opt.name}: ${opt.default},`) } }) optLines.push(' ...options,') optLines.push(' }') optLines.push('}\n') } return optLines.join('\n') } function getOptionDescriptor(opt: OptionSchema) { let desc = `--${opt.long ?? kebabCase(opt.name)}` if (opt.short) { desc += `,-${opt.short}` } return desc } function generateCommandDef(command: CommandSchema) { const commandPath = kebabCase(command.name) const avoidList = ['path', 'name'] const avoidName = (name: string) => { return avoidList.includes(name) ? '$$' + name : name } const prepare: string[] = [] const cmdLines: string[] = [] cmdLines.push(` export abstract class Base${PascalCase(command.name)}Command extends Command { static paths = [['${commandPath}']] static usage = Command.Usage({ description: '${command.description}', })\n`) command.args.forEach((arg) => { cmdLines.push( ` ${avoidName(arg.name)} = Option.String({ required: ${ arg.required ?? false } })`, ) }) cmdLines.push('') command.options.forEach((opt) => { const optName = avoidName(opt.name) let optionType = '' switch (opt.type) { case 'number': optionType = 'String' prepare.push("import * as typanion from 'typanion'") break case 'boolean': optionType = 'Boolean' break case 'string[]': optionType = 'Array' break case 'string': default: optionType = 'String' } const optionDesc = getOptionDescriptor(opt) if (opt.required) { cmdLines.push(` ${optName} = Option.${optionType}('${optionDesc}', {`) cmdLines.push(' required: true,') } else if (typeof opt.default !== 'undefined') { const defaultValue = typeof opt.default === 'number' ? `'${opt.default.toString()}'` : opt.default cmdLines.push(` ${optName} = Option.${optionType}(`) cmdLines.push(` '${optionDesc}',`) cmdLines.push(` ${defaultValue},`) cmdLines.push(` {`) } else { cmdLines.push( ` ${optName}?: ${opt.type} = Option.${optionType}('${optionDesc}', {`, ) } if (opt.type === 'number') { cmdLines.push(' validator: typanion.isNumber(),') } cmdLines.push(` description: '${opt.description}'`) cmdLines.push(' })\n') }) cmdLines.push(` getOptions() {`) cmdLines.push(` return {`) command.args .map(({ name }) => name) .concat(command.options.map(({ name }) => name)) .forEach((name) => { cmdLines.push(` ${name}: this.${avoidName(name)},`) }) cmdLines.push(' }') cmdLines.push(' }') cmdLines.push('}\n') return prepare.join('\n') + '\n' + cmdLines.join('\n') } function generateDocs(command: CommandSchema, targetFolder: string): string { const docsFileName = kebabCase(command.name) const docsFile = path.join(targetFolder, `${docsFileName}.md`) const options: string[] = [] command.args.forEach((arg) => { options.push( [ '', arg.name, `<${kebabCase(arg.name)}>`, arg.required ? 'true' : 'false', arg.type, '', arg.description, '', ].join('|'), ) }) command.options.forEach((opt) => { options.push( [ '', opt.name, getOptionDescriptor(opt), opt.type.replace(/\|/g, '\\|'), opt.required ? 'true' : 'false', opt.default ?? '', opt.description, '', ].join('|'), ) }) const content = `# ${startCase(command.name)} > This file is generated by cli/codegen. Do not edit this file manually. ${command.description} ## Usage \`\`\`sh # CLI napi ${kebabCase(command.name)}${command.args.reduce( (h, arg) => h + ` <${arg.name}>`, '', )} [--options] \`\`\` \`\`\`typescript // Programatically import { NapiCli } from '@napi-rs/cli' new NapiCli().${command.name}({ // options }) \`\`\` ## Options | Options | CLI Options | type | required | default | description | | ------- | ----------- | ---- | -------- | ------- | ----------- | | | --help,-h | | | | get help | ${options.join('\n')} ` // make sure the target folder exists fs.mkdirSync(targetFolder, { recursive: true }) // write file fs.writeFileSync(docsFile, content) return docsFile } function generateDef(cmd: CommandSchema, folder: string): string { const defFileName = kebabCase(cmd.name) const defFilePath = path.join(folder, `${defFileName}.ts`) const def = `// This file is generated by codegen/index.ts // Do not edit this file manually import { Command, Option } from 'clipanion' ${generateCommandDef(cmd)} ${generateOptionsDef(cmd)} ` // make sure the target folder exists fs.mkdirSync(folder, { recursive: true }) // write file fs.writeFileSync(defFilePath, def) return defFilePath } function codegen() { const outputs: string[] = [] commandDefines.forEach((command) => { outputs.push(generateDef(command, defFolder)) outputs.push(generateDocs(command, docsTargetFolder)) }) outputs.forEach((output) => { execSync(`yarn prettier -w ${output}`) }) } codegen()