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(prompt): rewrite codebase to use inquirer - UPDATED with current master #2697

Merged
merged 11 commits into from Nov 6, 2021
1 change: 1 addition & 0 deletions .eslintrc.js
Expand Up @@ -52,6 +52,7 @@ module.exports = {
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-inferrable-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/triple-slash-reference': 'off',

// TODO: enable those rules?
'no-empty': 'off',
Expand Down
6 changes: 2 additions & 4 deletions @commitlint/prompt-cli/cli.js
@@ -1,10 +1,8 @@
#!/usr/bin/env node
const execa = require('execa');
const inquirer = require('inquirer');
const {prompter} = require('@commitlint/prompt');

const _ = undefined;
const prompt = () => prompter(_, commit);

main().catch((err) => {
setTimeout(() => {
throw err;
Expand All @@ -21,7 +19,7 @@ function main() {
process.exit(1);
}
})
.then(() => prompt());
.then(() => prompter(inquirer, commit));
}

function isStageEmpty() {
Expand Down
1 change: 1 addition & 0 deletions @commitlint/prompt-cli/package.json
Expand Up @@ -37,6 +37,7 @@
},
"dependencies": {
"@commitlint/prompt": "^14.1.0",
"inquirer": "^6.5.2",
"execa": "^5.0.0"
},
"gitHead": "70f7f4688b51774e7ac5e40e896cdaa3f132b2bc"
Expand Down
10 changes: 7 additions & 3 deletions @commitlint/prompt/package.json
Expand Up @@ -38,15 +38,19 @@
},
"devDependencies": {
"@commitlint/utils": "^14.0.0",
"commitizen": "4.2.4"
"@commitlint/types": "^13.2.0",
"@commitlint/config-angular": "^13.2.0",
"@types/inquirer": "^6.5.0",
"inquirer": "^6.5.2",
"commitizen": "^4.2.4"
},
"dependencies": {
"@commitlint/ensure": "^14.1.0",
"@commitlint/load": "^14.1.0",
"@commitlint/types": "^14.0.0",
"chalk": "^4.0.0",
"throat": "^6.0.0",
"vorpal": "^1.12.0"
"lodash": "^4.17.19",
"inquirer": "^6.5.2"
},
"gitHead": "70f7f4688b51774e7ac5e40e896cdaa3f132b2bc"
}
16 changes: 4 additions & 12 deletions @commitlint/prompt/src/index.ts
@@ -1,18 +1,10 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import vorpal from 'vorpal';
import input from './input';
import inquirer from 'inquirer';
import {input} from './input';

type Commit = (input: string) => void;

/**
* Entry point for commitizen
* @param _ inquirer instance passed by commitizen, unused
* @param commit callback to execute with complete commit message
* @return {void}
*/
export function prompter(_: unknown, commit: Commit): void {
input(vorpal).then((message) => {
export function prompter(cz: typeof inquirer, commit: Commit): void {
input(cz.prompt).then((message) => {
commit(message);
});
}
96 changes: 96 additions & 0 deletions @commitlint/prompt/src/input.test.ts
@@ -0,0 +1,96 @@
import {Answers, PromptModule, QuestionCollection} from 'inquirer';
/// <reference path="./inquirer/inquirer.d.ts" />
import {input} from './input';
import chalk from 'chalk';

jest.mock(
'@commitlint/load',
() => {
return () => require('@commitlint/config-angular');
},
{
virtual: true,
}
);

test('should work with all fields filled', async () => {
const prompt = stub({
'input-custom': {
type: 'fix',
scope: 'test',
subject: 'subject',
body: 'body',
footer: 'footer',
},
});
const message = await input(prompt);
expect(message).toEqual('fix(test): subject\n' + 'body\n' + 'footer');
});

test('should work without scope', async () => {
const prompt = stub({
'input-custom': {
type: 'fix',
scope: '',
subject: 'subject',
body: 'body',
footer: 'footer',
},
});
const message = await input(prompt);
expect(message).toEqual('fix: subject\n' + 'body\n' + 'footer');
});

test('should fail without type', async () => {
const spy = jest.spyOn(console, 'error').mockImplementation();
const prompt = stub({
'input-custom': {
type: '',
scope: '',
subject: '',
body: '',
footer: '',
},
});
const message = await input(prompt);
expect(message).toEqual('');
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenLastCalledWith(
new Error(`⚠ ${chalk.bold('type')} may not be empty.`)
);
spy.mockRestore();
});

function stub(config: Record<string, Record<string, unknown>>): PromptModule {
const prompt = async (questions: QuestionCollection): Promise<any> => {
const result: Answers = {};
const resolvedConfig = Array.isArray(questions) ? questions : [questions];
for (const promptConfig of resolvedConfig) {
const configType = promptConfig.type || 'input';
const questions = config[configType];
if (!questions) {
throw new Error(`Unexpected config type: ${configType}`);
}
const answer = questions[promptConfig.name!];
if (answer == null) {
throw new Error(`Unexpected config name: ${promptConfig.name}`);
}
const validate = promptConfig.validate;
if (validate) {
const validationResult = validate(answer, result);
if (validationResult !== true) {
throw new Error(validationResult || undefined);
}
}

result[promptConfig.name!] = answer;
}
return result;
};
prompt.registerPrompt = () => {
return prompt;
};
prompt.restoreDefaultPrompts = () => true;
prompt.prompts = {};
return prompt as any as PromptModule;
}
65 changes: 27 additions & 38 deletions @commitlint/prompt/src/input.ts
@@ -1,29 +1,21 @@
import load from '@commitlint/load';
import throat from 'throat';
import {DistinctQuestion, PromptModule} from 'inquirer';

import format from './library/format';
import getPrompt from './library/get-prompt';
import settings from './settings';
import {InputSetting, Prompter, Result} from './library/types';
import {getHasName, getMaxLength, getRules} from './library/utils';
import type {InputSetting, Result} from './library/types';

export default input;
import {getHasName, getMaxLength, getRules} from './library/utils';
import InputCustomPrompt from './inquirer/InputCustomPrompt';

/**
* Get user input by interactive prompt based on
* conventional-changelog-lint rules.
* @param prompter
* @return commit message
*/
async function input(prompter: () => Prompter): Promise<string> {
const results: Result = {
type: null,
scope: null,
subject: null,
body: null,
footer: null,
};

export async function input(prompter: PromptModule): Promise<string> {
const {rules} = await load();
const parts = ['type', 'scope', 'subject', 'body', 'footer'] as const;
const headerParts = ['type', 'scope', 'subject'];
Expand All @@ -33,31 +25,28 @@ async function input(prompter: () => Prompter): Promise<string> {
);
const maxLength = getMaxLength(headerLengthRule);

await Promise.all(
parts.map(
throat(1, async (input) => {
const inputRules = getRules(input, rules);
const inputSettings: InputSetting = settings[input];

if (headerParts.includes(input) && maxLength < Infinity) {
inputSettings.header = {
length: maxLength,
};
}

results[input] = await getPrompt(input, {
rules: inputRules,
settings: inputSettings,
results,
prompter,
});
})
)
).catch((err) => {
try {
const questions: DistinctQuestion<Result>[] = [];
prompter.registerPrompt('input-custom', InputCustomPrompt);

for (const input of parts) {
const inputSetting: InputSetting = settings[input];
const inputRules = getRules(input, rules);
if (headerParts.includes(input) && maxLength < Infinity) {
inputSetting.header = {
length: maxLength,
};
}
const question = getPrompt(input, inputRules, inputSetting);
if (question) {
questions.push(question);
}
}

const results = await prompter<Result>(questions);
return format(results);
} catch (err) {
console.error(err);
return '';
});

// Return the results
return format(results);
}
}
117 changes: 117 additions & 0 deletions @commitlint/prompt/src/inquirer/InputCustomPrompt.ts
@@ -0,0 +1,117 @@
/// <reference path="./inquirer.d.ts" />
import chalk from 'chalk';
import inquirer from 'inquirer';
import InputPrompt from 'inquirer/lib/prompts/input';
import observe from 'inquirer/lib/utils/events';
import {Interface as ReadlineInterface, Key} from 'readline';
import type {Subscription} from 'rxjs/internal/Subscription';

import Answers = inquirer.Answers;
import InputCustomOptions = inquirer.InputCustomOptions;
import SuccessfulPromptStateData = inquirer.prompts.SuccessfulPromptStateData;

interface KeyDescriptor {
value: string;
key: Key;
}

export default class InputCustomPrompt<
TQuestion extends InputCustomOptions = InputCustomOptions
> extends InputPrompt<TQuestion> {
private lineSubscription: Subscription;
private readonly tabCompletion: string[];

constructor(
question: TQuestion,
readLine: ReadlineInterface,
answers: Answers
) {
super(question, readLine, answers);

if (this.opt.log) {
this.rl.write(this.opt.log(answers));
}

if (!this.opt.maxLength) {
this.throwParamError('maxLength');
}

const events = observe(this.rl);
this.lineSubscription = events.keypress.subscribe(
this.onKeyPress2.bind(this)
);
this.tabCompletion = (this.opt.tabCompletion || [])
.map((item) => item.value)
.sort((a, b) => a.localeCompare(b));
escapedcat marked this conversation as resolved.
Show resolved Hide resolved
}

onEnd(state: SuccessfulPromptStateData): void {
this.lineSubscription.unsubscribe();
super.onEnd(state);
}

/**
* @see https://nodejs.org/api/readline.html#readline_rl_write_data_key
* @see https://nodejs.org/api/readline.html#readline_rl_line
*/
updateLine(line: string): void {
this.rl.write(null as any, {ctrl: true, name: 'b'});
this.rl.write(null as any, {ctrl: true, name: 'd'});
this.rl.write(line.substr(this.rl.line.length));
}

onKeyPress2(e: KeyDescriptor): void {
if (e.key.name === 'tab' && this.tabCompletion.length > 0) {
let line = this.rl.line.trim();
if (line.length > 0) {
for (const item of this.tabCompletion) {
if (item.startsWith(line) && item !== line) {
line = item;
break;
}
}
}
this.updateLine(line);
}
}

measureInput(input: string): number {
if (this.opt.filter) {
return this.opt.filter(input).length;
}
return input.length;
}

render(error?: string): void {
const answered = this.status === 'answered';

let message = this.getQuestion();
const length = this.measureInput(this.rl.line);

if (answered) {
message += chalk.cyan(this.answer);
} else if (this.opt.transformer) {
message += this.opt.transformer(this.rl.line, this.answers, {});
}

let bottomContent = '';

if (error) {
bottomContent = chalk.red('>> ') + error;
} else if (!answered) {
const maxLength = this.opt.maxLength(this.answers);
if (maxLength < Infinity) {
const lengthRemaining = maxLength - length;
const color =
lengthRemaining <= 5
? chalk.red
: lengthRemaining <= 10
? chalk.yellow
: chalk.grey;
bottomContent = color(`${lengthRemaining} characters left`);
}
}

this.screen.render(message, bottomContent);
}
}