Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: autocomplete #4109

Draft
wants to merge 12 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions packages/webpack-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
4 changes: 4 additions & 0 deletions packages/webpack-cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ 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";

/**
* Webpack CLI
Expand Down Expand Up @@ -74,6 +75,9 @@ interface IWebpackCLI {
): Promise<WebpackCompiler>;
needWatchStdin(compiler: Compiler | MultiCompiler): boolean;
runWebpack(options: WebpackRunOptions, isWatchCommand: boolean): Promise<void>;
getAutocompleteTree(): IAutocompleteTree;
executeAutoComplete(): Promise<void>;
setupAutocompleteForShell(): Promise<void>;
}

interface WebpackCLIColors extends Colorette {
Expand Down
152 changes: 152 additions & 0 deletions packages/webpack-cli/src/utils/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -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<T>(array: T[]): T {
return array[0];
}

function tail<T>(array: T[]): T[] {
return array.slice(1);
}
129 changes: 129 additions & 0 deletions packages/webpack-cli/src/utils/helpers.ts
Original file line number Diff line number Diff line change
@@ -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",
},
];
};
Loading