diff --git a/citty.ts b/citty.ts index e69de29..fc4272c 100644 --- a/citty.ts +++ b/citty.ts @@ -0,0 +1,169 @@ +import { defineCommand } from "citty"; +import * as zsh from "./zsh"; +import * as bash from "./bash"; +import * as fish from "./fish"; +import * as powershell from "./powershell"; +import { + flagMap, + Positional, + positionalMap, + ShellCompDirective, +} from "./shared"; + +function quoteIfNeeded(path) { + return path.includes(" ") ? `'${path}'` : path; +} + +const execPath = process.execPath; +const processArgs = process.argv.slice(1); +const quotedExecPath = quoteIfNeeded(execPath); +const quotedProcessArgs = processArgs.map(quoteIfNeeded); +const quotedProcessExecArgs = process.execArgv.map(quoteIfNeeded); +const x = `${quotedExecPath} ${quotedProcessExecArgs.join(" ")} ${quotedProcessArgs[0]}`; + +export default function tab(mainCommand) { + mainCommand.subCommands["complete"] = defineCommand({ + meta: { + name: "complete", + description: "Generate shell completion scripts", + }, + args: { + shell: { + type: "positional", + required: false, + description: "Specify shell type", + }, + }, + async run(ctx) { + let shell: string | undefined = ctx.args.shell; + if (shell?.startsWith("--")) { + shell = undefined; + } + + const extra = ctx.args._ || []; + + switch (shell) { + case "zsh": { + const script = zsh.generate(mainCommand.meta.name, x); + console.log(script); + break; + } + case "bash": { + const script = bash.generate(mainCommand.meta.name, x); + console.log(script); + break; + } + case "fish": { + const script = fish.generate(mainCommand.meta.name, x); + console.log(script); + break; + } + case "powershell": { + const script = powershell.generate(mainCommand.meta.name, x); + console.log(script); + break; + } + default: { + const args = extra; + let directive = ShellCompDirective.ShellCompDirectiveDefault; + const completions: string[] = []; + const endsWithSpace = args[args.length - 1] === ""; + + if (endsWithSpace) args.pop(); + let toComplete = args[args.length - 1] || ""; + const previousArgs = args.slice(0, -1); + + let matchedCommand = mainCommand; + + if (previousArgs.length > 0) { + const lastPrevArg = previousArgs[previousArgs.length - 1]; + if (lastPrevArg.startsWith("--")) { + const flagCompletion = flagMap.get(lastPrevArg); + if (flagCompletion) { + const flagSuggestions = await flagCompletion(previousArgs, toComplete); + completions.push( + ...flagSuggestions.map( + (comp) => `${comp.action}\t${comp.description ?? ""}` + ) + ); + completions.forEach((comp) => console.log(comp)); + console.log(`:${directive}`); + return; + } + } + } + + if (toComplete.startsWith("--")) { + if (toComplete === "--") { + const allFlags = [...flagMap.keys()]; + completions.push( + ...allFlags.map( + (flag) => + `${flag}\t${matchedCommand.args[flag.slice(2)]?.description ?? "Option"}` + ) + ); + } else { + const flagNamePartial = toComplete.slice(2); + const flagKeyPartial = `--${flagNamePartial}`; + + if (flagMap.has(toComplete)) { + const flagCompletion = flagMap.get(toComplete); + if (flagCompletion) { + const flagSuggestions = await flagCompletion(previousArgs, ""); + completions.push( + ...flagSuggestions.map( + (comp) => `${comp.action}\t${comp.description ?? ""}` + ) + ); + } + } else { + const matchingFlags = [...flagMap.keys()].filter((flag) => + flag.startsWith(flagKeyPartial) + ); + + completions.push( + ...matchingFlags.map( + (flag) => + `${flag}\t${matchedCommand.args[flag.slice(2)]?.description ?? "Option"}` + ) + ); + } + } + + completions.forEach((comp) => console.log(comp)); + console.log(`:${directive}`); + return; + } + + if (previousArgs.length === 0) { + completions.push( + ...Object.keys(mainCommand.subCommands || {}) + .filter((cmd) => cmd !== "complete") + .map( + (cmd) => + `${cmd}\t${mainCommand.subCommands[cmd]?.meta.description ?? ""}` + ) + ); + } else { + const positionalCompletions = + positionalMap.get(matchedCommand.meta.name) || []; + for (const positional of positionalCompletions) { + const suggestions = await positional.completion( + previousArgs, + toComplete + ); + completions.push( + ...suggestions.map( + (comp) => `${comp.action}\t${comp.description ?? ""}` + ) + ); + } + } + + completions.forEach((comp) => console.log(comp)); + console.log(`:${directive}`); + } + } + }, + }); +} diff --git a/demo.citty.ts b/demo.citty.ts index 926cc89..52d8487 100644 --- a/demo.citty.ts +++ b/demo.citty.ts @@ -1,50 +1,25 @@ import { defineCommand, createMain } from "citty"; -// import { tab } from "./citty"; +import tab from "./citty"; +import { flagMap, positionalMap } from "./shared"; + const main = defineCommand({ meta: { name: "vite", description: "Vite CLI tool", }, args: { - config: { - type: "string", - description: "Use specified config file", - alias: "c", - }, - base: { - type: "string", - description: "Public base path (default: /)", - }, - logLevel: { - type: "string", - description: "info | warn | error | silent", - alias: "l", - }, - clearScreen: { - type: "boolean", - description: "Allow/disable clear screen when logging", - }, - debug: { - type: "string", - description: "Show debug logs", - alias: "d", - }, - filter: { - type: "string", - description: "Filter debug logs", - alias: "f", - }, - mode: { - type: "string", - description: "Set env mode", - alias: "m", - }, + config: { type: "string", description: "Use specified config file", alias: "c" }, + base: { type: "string", description: "Public base path (default: /)" }, + logLevel: { type: "string", description: "info | warn | error | silent", alias: "l" }, + clearScreen: { type: "boolean", description: "Allow/disable clear screen when logging" }, + debug: { type: "string", description: "Show debug logs", alias: "d" }, + filter: { type: "string", description: "Filter debug logs", alias: "f" }, + mode: { type: "string", description: "Set env mode", alias: "m" }, }, run(ctx) { - if (ctx.args._?.[0] !== "complete" && devCommand.run) { - devCommand.run(ctx); - } else if (!devCommand.run) { - console.error("Error: dev command is not defined."); + const firstArg = ctx.args._?.[0]; + if (firstArg && devCommand?.run) { + devCommand.run({ ...ctx, args: { ...ctx.args, root: firstArg } }); } }, }); @@ -55,71 +30,91 @@ const devCommand = defineCommand({ description: "Start dev server", }, args: { - root: { - type: "positional", - description: "Root directory", - required: false, - default: ".", - }, - host: { - type: "string", - description: "Specify hostname", - }, - port: { - type: "string", - description: "Specify port", - }, - open: { - type: "boolean", - description: "Open browser on startup", - }, - cors: { - type: "boolean", - description: "Enable CORS", - }, - strictPort: { - type: "boolean", - description: "Exit if specified port is already in use", - }, - force: { - type: "boolean", - description: "Force the optimizer to ignore the cache and re-bundle", - }, + root: { type: "positional", description: "Root directory", default: "." }, + host: { type: "string", description: "Specify hostname" }, + port: { type: "string", description: "Specify port" }, + open: { type: "boolean", description: "Open browser on startup" }, + cors: { type: "boolean", description: "Enable CORS" }, + strictPort: { type: "boolean", description: "Exit if specified port is already in use" }, + force: { type: "boolean", description: "Force the optimizer to ignore the cache and re-bundle" }, }, run({ args }) { const { root, port, ...options } = args; const parsedPort = port ? parseInt(port, 10) : undefined; - if (args._?.[0] !== "complete") { - if (!root || root === ".") { - console.log("Suggested root directories:"); - console.log("- src/"); - console.log("- ./"); - } + if (!root || root === ".") { + console.log("Suggested root directories:"); + console.log("- src/"); + console.log("- ./"); + } + }, +}); - const formattedOptions = { ...options, port: parsedPort }; - if (formattedOptions._) { - formattedOptions["--"] = formattedOptions._; - delete formattedOptions._; - } +main.subCommands = { dev: devCommand }; - const cleanedOptions = Object.fromEntries( - Object.entries(formattedOptions).filter(([_, v]) => v !== undefined) - ); +for (const command of [main, ...Object.values(main.subCommands)]) { + const commandName = command.meta.name; - console.log( - `Starting dev server at ${root || "."} with options:`, - cleanedOptions - ); + for (const [argName, argConfig] of Object.entries(command.args || {})) { + const optionKey = `--${argName}`; + if (argName === "port") { + flagMap.set(optionKey, async (_, toComplete) => { + const options = [ + { action: "3000", description: "Development server port" }, + { action: "8080", description: "Alternative port" }, + ]; + return toComplete + ? options.filter(comp => comp.action.startsWith(toComplete)) + : options; + }); } - }, -}); -main.subCommands = { - dev: devCommand, -}; + if (argName === "host") { + flagMap.set(optionKey, async (_, toComplete) => { + const options = [ + { action: "localhost", description: "Localhost" }, + { action: "0.0.0.0", description: "All interfaces" }, + ]; + return toComplete + ? options.filter(comp => comp.action.startsWith(toComplete)) + : options; + }); + } + + if (argName === "mode") { + flagMap.set(optionKey, async (_, toComplete) => { + const options = [ + { action: "development", description: "Development mode" }, + { action: "production", description: "Production mode" }, + ]; + return toComplete + ? options.filter(comp => comp.action.startsWith(toComplete)) + : options; + }); + } + } + + if (command.args) { + const positionals = Object.entries(command.args) + .filter(([, config]) => config.type === "positional") + .map(([argName, argConfig]) => ({ + value: argName, + required: !!argConfig.required, + completion: async (_, toComplete) => { + const options = [ + { action: "src/", description: "Source directory" }, + { action: "./", description: "Current directory" }, + ]; + return toComplete + ? options.filter(comp => comp.action.startsWith(toComplete)) + : options; + }, + })); + positionalMap.set(commandName, positionals); + } +} -// tab(main); +tab(main); const cli = createMain(main); cli();