diff --git a/cac.ts b/cac.ts index 0b73313..c59972a 100644 --- a/cac.ts +++ b/cac.ts @@ -1,22 +1,341 @@ -import cac from 'cac' +import cac, { Command } from "cac"; -const cli = cac('cac') +const cli = cac("cac"); +import mri, { Options } from "mri"; -cli.option('--type [type]', 'Choose a project type', { - default: 'node', -}) -cli.option('--name ', 'Provide your name') +cli.option("--type [type]", "Choose a project type", { + default: "node", +}); +cli.option("--name ", "Provide your name"); -cli.command('lint [...files]', 'Lint files').action((files, options) => { - console.log(files, options) -}) +cli.command("start").option("--port ", "your port"); -// Display help message when `-h` or `--help` appears -cli.help() -// Display version number when `-v` or `--version` appears -// It's also used in help message -cli.version('0.0.0') +cli.command("help"); -console.log('cli', cli) +cli.command("help config").option("--foo", "foo option"); +cli + .command("complete") + .action(() => console.log(genZshComp("my_command", true))); +cli + .command("__complete [...args]") + .action((_, { ["--"]: args }: { ["--"]: string[] }) => { + const toComplete = args[args.length - 1]; + const trimmedArgs = args.slice(0, args.length - 1); + + // console.log("found", cli.parse(trimmedArgs)) + const parsed = mri(trimmedArgs); + const commandName = parsed._.join(" "); + + let command: Command | null = null; + for (const c of cli.commands) { + if (c.isMatched(commandName)) { + command = c; + } + } + const completions: string[] = []; + + if (toComplete.startsWith("-")) { + const flag = toComplete.slice("--".length); + const options: Command["options"] = [ + ...(command?.options ?? []), + ...cli.globalCommand.options, + ]; + if (flag) { + completions.push( + ...options + .filter((o) => o.names.some((name) => name.includes(flag))) + .map((o) => `--${o.name}`), + ); + } else { + } + + } + + // console.log(args, toComplete, commandName); + // console.log(cli); + console.log(`${completions.join("\n")}\n`); + }); + +cli.version("0.0.0"); +cli.help(); + +// console.log('cli', cli) + +// ShellCompRequestCmd is the name of the hidden command that is used to request +// completion results from the program. It is used by the shell completion scripts. +const ShellCompRequestCmd: string = "__complete"; + +// ShellCompNoDescRequestCmd is the name of the hidden command that is used to request +// completion results without their description. It is used by the shell completion scripts. +const ShellCompNoDescRequestCmd: string = "__completeNoDesc"; + +// ShellCompDirective is a bit map representing the different behaviors the shell +// can be instructed to have once completions have been provided. +enum ShellCompDirective { + // ShellCompDirectiveError indicates an error occurred and completions should be ignored. + ShellCompDirectiveError = 1 << 0, + + // ShellCompDirectiveNoSpace indicates that the shell should not add a space + // after the completion even if there is a single completion provided. + ShellCompDirectiveNoSpace = 1 << 1, + + // ShellCompDirectiveNoFileComp indicates that the shell should not provide + // file completion even when no completion is provided. + ShellCompDirectiveNoFileComp = 1 << 2, + + // ShellCompDirectiveFilterFileExt indicates that the provided completions + // should be used as file extension filters. + // For flags, using Command.MarkFlagFilename() and Command.MarkPersistentFlagFilename() + // is a shortcut to using this directive explicitly. The BashCompFilenameExt + // annotation can also be used to obtain the same behavior for flags. + ShellCompDirectiveFilterFileExt = 1 << 3, + + // ShellCompDirectiveFilterDirs indicates that only directory names should + // be provided in file completion. To request directory names within another + // directory, the returned completions should specify the directory within + // which to search. The BashCompSubdirsInDir annotation can be used to + // obtain the same behavior but only for flags. + ShellCompDirectiveFilterDirs = 1 << 4, + + // ShellCompDirectiveKeepOrder indicates that the shell should preserve the order + // in which the completions are provided. + ShellCompDirectiveKeepOrder = 1 << 5, + + // =========================================================================== + + // All directives using iota (or equivalent in Go) should be above this one. + // For internal use. + shellCompDirectiveMaxValue = 1 << 6, + + // ShellCompDirectiveDefault indicates to let the shell perform its default + // behavior after completions have been provided. + // This one must be last to avoid messing up the iota count. + ShellCompDirectiveDefault = 0, +} + +const execPath = process.execPath; +const args = process.argv.slice(1); +const x = `${execPath} ${process.execArgv.join(" ")} ${args[0]}`; +// console.error(x) + +function genZshComp(name: string, includeDesc: boolean) { + let compCmd = ShellCompRequestCmd; + if (!includeDesc) { + compCmd = ShellCompNoDescRequestCmd; + } + + return `#compdef ${name} +compdef _${name} ${name} + +# zsh completion for ${name} -*- shell-script -*- + +__${name}_debug() { + local file="$BASH_COMP_DEBUG_FILE" + if [[ -n \${file} ]]; then + echo "$*" >> "\${file}" + fi +} + +_${name}() { + local shellCompDirectiveError=${ShellCompDirective.ShellCompDirectiveError} + local shellCompDirectiveNoSpace=${ShellCompDirective.ShellCompDirectiveNoSpace} + local shellCompDirectiveNoFileComp=${ShellCompDirective.ShellCompDirectiveNoFileComp} + local shellCompDirectiveFilterFileExt=${ShellCompDirective.ShellCompDirectiveFilterFileExt} + local shellCompDirectiveFilterDirs=${ShellCompDirective.ShellCompDirectiveFilterDirs} + local shellCompDirectiveKeepOrder=${ShellCompDirective.ShellCompDirectiveKeepOrder} + + local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder + local -a completions + + __${name}_debug "\\n========= starting completion logic ==========" + __${name}_debug "CURRENT: \${CURRENT}, words[*]: \${words[*]}" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the \$CURRENT location, so we need + # to truncate the command-line (\$words) up to the \$CURRENT location. + # (We cannot use \$CURSOR as its value does not work when a command is an alias.) + words=( "\${=words[1,CURRENT]}" ) + __${name}_debug "Truncated words[*]: \${words[*]}," + + lastParam=\${words[-1]} + lastChar=\${lastParam[-1]} + __${name}_debug "lastParam: \${lastParam}, lastChar: \${lastChar}" + + # For zsh, when completing a flag with an = (e.g., ${name} -n=) + # completions must be prefixed with the flag + setopt local_options BASH_REMATCH + if [[ "\${lastParam}" =~ '-.*=' ]]; then + # We are dealing with a flag with an = + flagPrefix="-P \${BASH_REMATCH}" + fi + + # Prepare the command to obtain completions + requestComp="${x} ${compCmd} -- \${words[2,-1]}" + if [ "\${lastChar}" = "" ]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go completion code. + __${name}_debug "Adding extra empty parameter" + requestComp="\${requestComp} \"\"" + fi + + __${name}_debug "About to call: eval \${requestComp}" + + # Use eval to handle any environment variables and such + out=\$(eval \${requestComp} 2>/dev/null) + __${name}_debug "completion output: \${out}" + + # Extract the directive integer following a : from the last line + local lastLine + while IFS='\n' read -r line; do + lastLine=\${line} + done < <(printf "%s\n" "\${out[@]}") + __${name}_debug "last line: \${lastLine}" + + if [ "\${lastLine[1]}" = : ]; then + directive=\${lastLine[2,-1]} + # Remove the directive including the : and the newline + local suffix + (( suffix=\${#lastLine}+2)) + out=\${out[1,-\$suffix]} + else + # There is no directive specified. Leave \$out as is. + __${name}_debug "No directive found. Setting to default" + directive=0 + fi + + __${name}_debug "directive: \${directive}" + __${name}_debug "completions: \${out}" + __${name}_debug "flagPrefix: \${flagPrefix}" + + if [ \$((directive & shellCompDirectiveError)) -ne 0 ]; then + __${name}_debug "Completion received error. Ignoring completions." + return + fi + + local activeHelpMarker="%" + local endIndex=\${#activeHelpMarker} + local startIndex=\$((\${#activeHelpMarker}+1)) + local hasActiveHelp=0 + while IFS='\n' read -r comp; do + # Check if this is an activeHelp statement (i.e., prefixed with \$activeHelpMarker) + if [ "\${comp[1,\$endIndex]}" = "\$activeHelpMarker" ];then + __${name}_debug "ActiveHelp found: \$comp" + comp="\${comp[\$startIndex,-1]}" + if [ -n "\$comp" ]; then + compadd -x "\${comp}" + __${name}_debug "ActiveHelp will need delimiter" + hasActiveHelp=1 + fi + continue + fi + + if [ -n "\$comp" ]; then + # If requested, completions are returned with a description. + # The description is preceded by a TAB character. + # For zsh's _describe, we need to use a : instead of a TAB. + # We first need to escape any : as part of the completion itself. + comp=\${comp//:/\\:} + + local tab="\$(printf '\\t')" + comp=\${comp//\$tab/:} + + __${name}_debug "Adding completion: \${comp}" + completions+=\${comp} + lastComp=\$comp + fi + done < <(printf "%s\n" "\${out[@]}") + + # Add a delimiter after the activeHelp statements, but only if: + # - there are completions following the activeHelp statements, or + # - file completion will be performed (so there will be choices after the activeHelp) + if [ \$hasActiveHelp -eq 1 ]; then + if [ \${#completions} -ne 0 ] || [ \$((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then + __${name}_debug "Adding activeHelp delimiter" + compadd -x "--" + hasActiveHelp=0 + fi + fi + + if [ \$((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then + __${name}_debug "Activating nospace." + noSpace="-S ''" + fi + + if [ \$((directive & shellCompDirectiveKeepOrder)) -ne 0 ]; then + __${name}_debug "Activating keep order." + keepOrder="-V" + fi + + if [ \$((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then + # File extension filtering + local filteringCmd + filteringCmd='_files' + for filter in \${completions[@]}; do + if [ \${filter[1]} != '*' ]; then + # zsh requires a glob pattern to do file filtering + filter="\\*.\$filter" + fi + filteringCmd+=" -g \$filter" + done + filteringCmd+=" \${flagPrefix}" + + __${name}_debug "File filtering command: \$filteringCmd" + _arguments '*:filename:'"\$filteringCmd" + elif [ \$((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then + # File completion for directories only + local subdir + subdir="\${completions[1]}" + if [ -n "\$subdir" ]; then + __${name}_debug "Listing directories in \$subdir" + pushd "\${subdir}" >/dev/null 2>&1 + else + __${name}_debug "Listing directories in ." + fi + + local result + _arguments '*:dirname:_files -/'" \${flagPrefix}" + result=\$? + if [ -n "\$subdir" ]; then + popd >/dev/null 2>&1 + fi + return \$result + else + __${name}_debug "Calling _describe" + if eval _describe \$keepOrder "completions" completions \${flagPrefix} \${noSpace}; then + __${name}_debug "_describe found some completions" + + # Return the success of having called _describe + return 0 + else + __${name}_debug "_describe did not find completions." + __${name}_debug "Checking if we should do file completion." + if [ \$((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + __${name}_debug "deactivating file completion" + + # We must return an error code here to let zsh know that there were no + # completions found by _describe; this is what will trigger other + # matching algorithms to attempt to find completions. + # For example zsh can match letters in the middle of words. + return 1 + else + # Perform file completion + __${name}_debug "Activating file completion" + + # We must return the result of this command, so it must be the + # last command, or else we must store its result to return it. + _arguments '*:filename:_files'" \${flagPrefix}" + fi + fi + fi +} + +# don't run the completion function when being sourced or eval-ed +if [ "\${funcstack[1]}" = "_${name}" ]; then + _${name} +fi +`; +} + +cli.parse() diff --git a/package.json b/package.json index 58d9bc0..35f5428 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,11 @@ "author": "", "license": "ISC", "devDependencies": { + "@types/node": "^22.7.4", "cac": "^6.7.14", "tsx": "^4.19.1" + }, + "dependencies": { + "mri": "^1.2.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11ac393..e2e96df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,14 @@ settings: importers: .: + dependencies: + mri: + specifier: ^1.2.0 + version: 1.2.0 devDependencies: + '@types/node': + specifier: ^22.7.4 + version: 22.7.4 cac: specifier: ^6.7.14 version: 6.7.14 @@ -161,6 +168,9 @@ packages: cpu: [x64] os: [win32] + '@types/node@22.7.4': + resolution: {integrity: sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -178,6 +188,10 @@ packages: get-tsconfig@4.8.1: resolution: {integrity: sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==} + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -186,6 +200,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + snapshots: '@esbuild/aix-ppc64@0.23.1': @@ -260,6 +277,10 @@ snapshots: '@esbuild/win32-x64@0.23.1': optional: true + '@types/node@22.7.4': + dependencies: + undici-types: 6.19.8 + cac@6.7.14: {} esbuild@0.23.1: @@ -296,6 +317,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + mri@1.2.0: {} + resolve-pkg-maps@1.0.0: {} tsx@4.19.1: @@ -304,3 +327,5 @@ snapshots: get-tsconfig: 4.8.1 optionalDependencies: fsevents: 2.3.3 + + undici-types@6.19.8: {}