diff --git a/packages/webpack-cli/package.json b/packages/webpack-cli/package.json index 7bd96204142..8af876885b1 100644 --- a/packages/webpack-cli/package.json +++ b/packages/webpack-cli/package.json @@ -45,6 +45,7 @@ "fastest-levenshtein": "^1.0.12", "import-local": "^3.0.2", "interpret": "^3.1.1", + "omelette": "^0.4.17", "rechoir": "^0.8.0", "webpack-merge": "^5.7.3" }, diff --git a/packages/webpack-cli/src/types.ts b/packages/webpack-cli/src/types.ts index cebc66e5932..01900f1df31 100644 --- a/packages/webpack-cli/src/types.ts +++ b/packages/webpack-cli/src/types.ts @@ -22,6 +22,10 @@ import type { ClientConfiguration, Configuration as DevServerConfig } from "webp import { type Colorette } from "colorette"; import { type Command, type CommandOptions, type Option, type ParseOptions } from "commander"; import { type prepare } from "rechoir"; + +// import { type stringifyStream } from "@discoveryjs/json-ext"; +import { IAutocompleteTree } from "./utils/autocomplete"; + import { type stringifyChunked } from "@discoveryjs/json-ext"; /** @@ -74,6 +78,9 @@ interface IWebpackCLI { ): Promise; needWatchStdin(compiler: Compiler | MultiCompiler): boolean; runWebpack(options: WebpackRunOptions, isWatchCommand: boolean): Promise; + getAutocompleteTree(): IAutocompleteTree; + executeAutoComplete(): Promise; + setupAutocompleteForShell(): Promise; } interface WebpackCLIColors extends Colorette { diff --git a/packages/webpack-cli/src/utils/autocomplete.ts b/packages/webpack-cli/src/utils/autocomplete.ts new file mode 100644 index 00000000000..f9019d1051a --- /dev/null +++ b/packages/webpack-cli/src/utils/autocomplete.ts @@ -0,0 +1,152 @@ +import * as Fs from "fs"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const omelette = require("omelette"); + +export const appNameOnAutocomplete = "webpack-cli"; + +function getAutoCompleteObject() { + return omelette(appNameOnAutocomplete); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function setupAutoCompleteForShell(path?: string, shell?: string): any { + const autoCompleteObject = getAutoCompleteObject(); + let initFile = path; + + if (shell) { + autoCompleteObject.shell = shell; + } else { + autoCompleteObject.shell = autoCompleteObject.getActiveShell(); + } + + if (!initFile) { + initFile = autoCompleteObject.getDefaultShellInitFile(); + } + + let initFileContent; + + try { + initFileContent = Fs.readFileSync(initFile as string, { encoding: "utf-8" }); + } catch (exception) { + throw `Can't read init file (${initFile}): ${exception}`; + } + + try { + // For bash we need to enable bash_completion before webpack cli completion + if ( + autoCompleteObject.shell === "bash" && + initFileContent.indexOf("begin bash_completion configuration") === -1 + ) { + const sources = `[ -f /usr/local/etc/bash_completion ] && . /usr/local/etc/bash_completion +[ -f /usr/share/bash-completion/bash_completion ] && . /usr/share/bash-completion/bash_completion +[ -f /etc/bash_completion ] && . /etc/bash_completion`; + + const template = ` +# begin bash_completion configuration for ${appNameOnAutocomplete} completion +${sources} +# end bash_completion configuration for ${appNameOnAutocomplete} completion +`; + + Fs.appendFileSync(initFile as string, template); + } + + if (initFileContent.indexOf(`begin ${appNameOnAutocomplete} completion`) === -1) { + autoCompleteObject.setupShellInitFile(initFile); + } + } catch (exception) { + throw `Can't setup autocomplete. Please make sure that init file (${initFile}) exist and you have write permissions: ${exception}`; + } +} + +export function getReplyHandler( + lineEndsWithWhitespaceChar: boolean, +): (args: string[], autocompleteTree: IAutocompleteTree) => string[] { + return function getReply(args: string[], autocompleteTree: IAutocompleteTree): string[] { + const currentArg = head(args); + const commandsAndCategories = Object.keys(autocompleteTree); + + if (currentArg === undefined) { + // no more args - show all of the items at the current level + return commandsAndCategories; + } else { + // check what arg points to + const entity = autocompleteTree[currentArg]; + if (entity) { + // arg points to an existing command or category + const restOfArgs = tail(args); + if (restOfArgs.length || lineEndsWithWhitespaceChar) { + if (entity instanceof Array) { + // it is command + const getCommandReply = getCommandReplyHandler(lineEndsWithWhitespaceChar); + return getCommandReply(restOfArgs, entity); + } else { + // it is category + return getReply(restOfArgs, entity); + } + } else { + // if last arg has no trailing whitespace, it should be added + return [currentArg]; + } + } else { + // arg points to nothing specific - return commands and categories which start with arg + return commandsAndCategories.filter((commandOrCategory) => + commandOrCategory.startsWith(currentArg), + ); + } + } + }; +} + +function getCommandReplyHandler( + lineEndsWithWhitespaceChar: boolean, +): (args: string[], optionNames: IOptionNames[]) => string[] { + return function getCommandReply(args: string[], optionsNames: IOptionNames[]): string[] { + const currentArg = head(args); + if (currentArg === undefined) { + // no more args, returning remaining optionsNames + return optionsNames.map((option) => (option.long as string) || (option.short as string)); + } else { + const restOfArgs = tail(args); + if (restOfArgs.length || lineEndsWithWhitespaceChar) { + const filteredOptions = optionsNames.filter( + (option) => option.long !== currentArg && option.short !== currentArg, + ); + return getCommandReply(restOfArgs, filteredOptions); + } else { + const candidates: string[] = []; + for (const option of optionsNames) { + if (option.long && option.long.startsWith(currentArg)) { + candidates.push(option.long); + } else if (option.short && option.short.startsWith(currentArg)) { + candidates.push(option.short); + } + } + return candidates; + } + } + }; +} + +interface IOptionNames { + short?: string; + long?: string; +} + +export interface IAutocompleteTree { + [entity: string]: IAutocompleteTree | IOptionNames[]; +} + +// utility functions (to avoid loading lodash for performance reasons) + +function last(line: string): string { + return line.substr(-1, 1); +} + +function head(array: T[]): T { + return array[0]; +} + +function tail(array: T[]): T[] { + return array.slice(1); +} diff --git a/packages/webpack-cli/src/utils/helpers.ts b/packages/webpack-cli/src/utils/helpers.ts new file mode 100644 index 00000000000..ac2f5c927af --- /dev/null +++ b/packages/webpack-cli/src/utils/helpers.ts @@ -0,0 +1,129 @@ +import { + CommandAction, + WebpackCLIBuiltInOption, + WebpackCLICommand, + WebpackCLICommandOptions, + WebpackCLIExternalCommandInfo, + WebpackCLIOptions, +} from "../types"; +import { IAutocompleteTree } from "./autocomplete"; + +const WEBPACK_PACKAGE_IS_CUSTOM = !!process.env.WEBPACK_PACKAGE; +const WEBPACK_PACKAGE = WEBPACK_PACKAGE_IS_CUSTOM + ? (process.env.WEBPACK_PACKAGE as string) + : "webpack"; + +export const getKnownCommands = (): WebpackCLIOptions[] => { + // Built-in internal commands + const buildCommandOptions = { + name: "build [entries...]", + alias: ["bundle", "b"], + description: "Run webpack (default command, can be omitted).", + usage: "[entries...] [options]", + dependencies: [WEBPACK_PACKAGE], + }; + const watchCommandOptions = { + name: "watch [entries...]", + alias: "w", + description: "Run webpack and watch for files changes.", + usage: "[entries...] [options]", + dependencies: [WEBPACK_PACKAGE], + }; + const versionCommandOptions = { + name: "version", + alias: "v", + usage: "[options]", + description: + "Output the version number of 'webpack', 'webpack-cli' and 'webpack-dev-server' and commands.", + }; + const setupAutocompleteCommandOptions = { + name: "setup-autocomplete", + alias: "a", + usage: "[options]", + description: "Setup tab completion for your shell", + }; + const helpCommandOptions = { + name: "help [command] [option]", + alias: "h", + description: "Display help for commands and options.", + }; + // Built-in external commands + const externalBuiltInCommandsInfo: WebpackCLIExternalCommandInfo[] = [ + { + name: "serve [entries...]", + alias: ["server", "s"], + pkg: "@webpack-cli/serve", + }, + { + name: "info", + alias: "i", + pkg: "@webpack-cli/info", + }, + { + name: "init", + alias: ["create", "new", "c", "n"], + pkg: "@webpack-cli/generators", + }, + { + name: "loader", + alias: "l", + pkg: "@webpack-cli/generators", + }, + { + name: "plugin", + alias: "p", + pkg: "@webpack-cli/generators", + }, + { + name: "configtest [config-path]", + alias: "t", + pkg: "@webpack-cli/configtest", + }, + ]; + + const knownCommands = [ + buildCommandOptions, + watchCommandOptions, + versionCommandOptions, + helpCommandOptions, + setupAutocompleteCommandOptions, + ...externalBuiltInCommandsInfo, + ]; + + return knownCommands; +}; + +export const getExternalBuiltInCommandsInfo = (): WebpackCLIExternalCommandInfo[] => { + return [ + { + name: "serve [entries...]", + alias: ["server", "s"], + pkg: "@webpack-cli/serve", + }, + { + name: "info", + alias: "i", + pkg: "@webpack-cli/info", + }, + { + name: "init", + alias: ["create", "new", "c", "n"], + pkg: "@webpack-cli/generators", + }, + { + name: "loader", + alias: "l", + pkg: "@webpack-cli/generators", + }, + { + name: "plugin", + alias: "p", + pkg: "@webpack-cli/generators", + }, + { + name: "configtest [config-path]", + alias: "t", + pkg: "@webpack-cli/configtest", + }, + ]; +}; diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index a4a82de9c78..f1b22501e95 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -55,12 +55,22 @@ import { type stringifyChunked } from "@discoveryjs/json-ext"; import { type Help, type ParseOptions } from "commander"; import { type CLIPlugin as CLIPluginClass } from "./plugins/cli-plugin"; + +import { + IAutocompleteTree, + appNameOnAutocomplete, + getReplyHandler, + setupAutoCompleteForShell, +} from "./utils/autocomplete"; +import { getExternalBuiltInCommandsInfo, getKnownCommands } from "./utils/helpers"; + const fs = require("fs"); const { Readable } = require("stream"); const path = require("path"); const { pathToFileURL } = require("url"); const util = require("util"); const { program, Option } = require("commander"); +const omelette = require("omelette"); const WEBPACK_PACKAGE_IS_CUSTOM = !!process.env.WEBPACK_PACKAGE; const WEBPACK_PACKAGE = WEBPACK_PACKAGE_IS_CUSTOM @@ -1098,74 +1108,8 @@ class WebpackCLI implements IWebpackCLI { } async run(args: Parameters[0], parseOptions: ParseOptions) { - // Built-in internal commands - const buildCommandOptions = { - name: "build [entries...]", - alias: ["bundle", "b"], - description: "Run webpack (default command, can be omitted).", - usage: "[entries...] [options]", - dependencies: [WEBPACK_PACKAGE], - }; - const watchCommandOptions = { - name: "watch [entries...]", - alias: "w", - description: "Run webpack and watch for files changes.", - usage: "[entries...] [options]", - dependencies: [WEBPACK_PACKAGE], - }; - const versionCommandOptions = { - name: "version", - alias: "v", - usage: "[options]", - description: - "Output the version number of 'webpack', 'webpack-cli' and 'webpack-dev-server' and commands.", - }; - const helpCommandOptions = { - name: "help [command] [option]", - alias: "h", - description: "Display help for commands and options.", - }; - // Built-in external commands - const externalBuiltInCommandsInfo: WebpackCLIExternalCommandInfo[] = [ - { - name: "serve [entries...]", - alias: ["server", "s"], - pkg: "@webpack-cli/serve", - }, - { - name: "info", - alias: "i", - pkg: "@webpack-cli/info", - }, - { - name: "init", - alias: ["create", "new", "c", "n"], - pkg: "@webpack-cli/generators", - }, - { - name: "loader", - alias: "l", - pkg: "@webpack-cli/generators", - }, - { - name: "plugin", - alias: "p", - pkg: "@webpack-cli/generators", - }, - { - name: "configtest [config-path]", - alias: "t", - pkg: "@webpack-cli/configtest", - }, - ]; + const knownCommands = getKnownCommands(); - const knownCommands = [ - buildCommandOptions, - watchCommandOptions, - versionCommandOptions, - helpCommandOptions, - ...externalBuiltInCommandsInfo, - ]; const getCommandName = (name: string) => name.split(" ")[0]; const isKnownCommand = (name: string) => knownCommands.find( @@ -1173,6 +1117,14 @@ class WebpackCLI implements IWebpackCLI { getCommandName(command.name) === name || (Array.isArray(command.alias) ? command.alias.includes(name) : command.alias === name), ); + //duplicate logic from isKnownCommand: Done to prevent readability issues + const getKnownCommand = (name: string): WebpackCLIOptions => + knownCommands.find( + (command) => + getCommandName(command.name) == name || + (Array.isArray(command.alias) ? command.alias.includes(name) : command.alias == name), + ) || knownCommands[0]; + const isCommand = (input: string, commandOptions: WebpackCLIOptions) => { const longName = getCommandName(commandOptions.name); @@ -1207,12 +1159,12 @@ class WebpackCLI implements IWebpackCLI { commandName: WebpackCLIExternalCommandInfo["name"], allowToInstall = false, ) => { - const isBuildCommandUsed = isCommand(commandName, buildCommandOptions); - const isWatchCommandUsed = isCommand(commandName, watchCommandOptions); + const isBuildCommandUsed = isCommand(commandName, getKnownCommand("build")); + const isWatchCommandUsed = isCommand(commandName, getKnownCommand("watch")); if (isBuildCommandUsed || isWatchCommandUsed) { await this.makeCommand( - isBuildCommandUsed ? buildCommandOptions : watchCommandOptions, + isBuildCommandUsed ? getKnownCommand("build") : getKnownCommand("watch"), async () => { this.webpack = await this.loadWebpack(); @@ -1226,13 +1178,13 @@ class WebpackCLI implements IWebpackCLI { await this.runWebpack(options, isWatchCommandUsed); }, ); - } else if (isCommand(commandName, helpCommandOptions)) { + } else if (isCommand(commandName, getKnownCommand("help"))) { // Stub for the `help` command - this.makeCommand(helpCommandOptions, [], () => {}); - } else if (isCommand(commandName, versionCommandOptions)) { + this.makeCommand(getKnownCommand("help"), [], () => {}); + } else if (isCommand(commandName, getKnownCommand("version"))) { // Stub for the `version` command this.makeCommand( - versionCommandOptions, + getKnownCommand("version"), this.getInfoOptions(), async (options: { output: string; additionalPackage: string[] }) => { const info = await cli.getInfoOutput(options); @@ -1240,8 +1192,12 @@ class WebpackCLI implements IWebpackCLI { cli.logger.raw(info); }, ); + } else if (isCommand(commandName, getKnownCommand("setup-autocomplete"))) { + this.makeCommand(getKnownCommand("setup-autocomplete"), [], async () => { + await this.setupAutocompleteForShell(); + }); } else { - const builtInExternalCommandInfo = externalBuiltInCommandsInfo.find( + const builtInExternalCommandInfo = getExternalBuiltInCommandsInfo().find( (externalBuiltInCommandInfo) => getCommandName(externalBuiltInCommandInfo.name) === commandName || (Array.isArray(externalBuiltInCommandInfo.alias) @@ -1323,7 +1279,7 @@ class WebpackCLI implements IWebpackCLI { const operand = typeof operands[0] !== "undefined" ? operands[0] - : getCommandName(buildCommandOptions.name); + : getCommandName(getKnownCommand("build").name); if (operand) { const command = findCommandByName(operand); @@ -1559,7 +1515,7 @@ class WebpackCLI implements IWebpackCLI { }), ); - const buildCommand = findCommandByName(getCommandName(buildCommandOptions.name)); + const buildCommand = findCommandByName(getCommandName(getKnownCommand("build").name)); if (buildCommand) { this.logger.raw(buildCommand.helpInformation()); @@ -1572,7 +1528,7 @@ class WebpackCLI implements IWebpackCLI { const command = findCommandByName(name); if (!command) { - const builtInCommandUsed = externalBuiltInCommandsInfo.find( + const builtInCommandUsed = getExternalBuiltInCommandsInfo().find( (command) => command.name.includes(name) || name === command.alias, ); if (typeof builtInCommandUsed !== "undefined") { @@ -1590,7 +1546,7 @@ class WebpackCLI implements IWebpackCLI { } } else if (isHelpCommandSyntax) { let isCommandSpecified = false; - let commandName = getCommandName(buildCommandOptions.name); + let commandName = getCommandName(getKnownCommand("build").name); let optionName = ""; if (options.length === 1) { @@ -1716,11 +1672,11 @@ class WebpackCLI implements IWebpackCLI { // Command and options const { operands, unknown } = this.program.parseOptions(program.args); - const defaultCommandToRun = getCommandName(buildCommandOptions.name); + const defaultCommandToRun = getCommandName(getKnownCommand("build").name); const hasOperand = typeof operands[0] !== "undefined"; const operand = hasOperand ? operands[0] : defaultCommandToRun; const isHelpOption = typeof options.help !== "undefined"; - const isHelpCommandSyntax = isCommand(operand, helpCommandOptions); + const isHelpCommandSyntax = isCommand(operand, getKnownCommand("help")); if (isHelpOption || isHelpCommandSyntax) { let isVerbose = false; @@ -1804,6 +1760,8 @@ class WebpackCLI implements IWebpackCLI { }); }); + this.executeAutoComplete(); + await this.program.parseAsync(args, parseOptions); } @@ -2568,6 +2526,81 @@ class WebpackCLI implements IWebpackCLI { } } } + + getAutocompleteTree(): IAutocompleteTree { + const knownCommands = getKnownCommands(); + + const getCommandName = (name: string) => name.split(" ")[0]; + const autocompleteTree: IAutocompleteTree = {}; + // knownCommands.forEach(command => { + // allCommandNames.push(getCommandName(command.name)) + // }) + knownCommands.forEach((command) => { + autocompleteTree[getCommandName(command.name)] = {}; + }); + + return autocompleteTree; + } + + async executeAutoComplete(): Promise { + function last(line: string): string { + return line.substr(-1, 1); + } + + // const autocompleteTree = { + // build: [ + // { + // long: "--config", + // }, + // { + // long: "--stats", + // }, + // ], + // } as IAutocompleteTree; + + const autocompleteTree = this.getAutocompleteTree(); + + const autoCompleteObject = omelette(appNameOnAutocomplete); + + autoCompleteObject.on( + "complete", + function ( + // fragment: string, **keep this as it is** + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: { before: string; fragment: number; line: string; reply: (answer: any) => void }, + ) { + const line = data.line; + const reply = data.reply; + const argsLine = line.substring(appNameOnAutocomplete.length); + const args = argsLine.match(/\S+/g) || []; + const lineEndsWithWhitespaceChar = /\s{1}/.test(last(line)); + + const getReply = getReplyHandler(lineEndsWithWhitespaceChar); + + reply(getReply(args, autocompleteTree)); + }, + ); + + autoCompleteObject.init(); + } + + async setupAutocompleteForShell() { + const supportedShells: string[] = ["bash", "zsh", "fish"]; + let shell!: string; + let shellProfilePath!: string; + + if (!shell && (!process.env.SHELL || !process.env.SHELL.match(supportedShells.join("|")))) { + this.logger.error("Current shell cannot be detected, please specify it explicitly"); + } + + process.on("exit", (code: number) => { + if (code === 0) { + this.logger.success("Please restart shell to apply changes"); + } + }); + + setupAutoCompleteForShell(shellProfilePath, shell); + } } module.exports = WebpackCLI;