Skip to content

Commit

Permalink
feat(prompt): rewrite codebase to use inquirer - UPDATED with current…
Browse files Browse the repository at this point in the history
… master (#2697)

* feat(prompt): rewrite codebase to use inquirer

* fix(prompt): simplify logic used to compute maxLength

* test(prompt): add basic input test

* fix(prompt): small code refactor

* fix: correct linting issues, add missing dependencies

* fix: add missing tsconfig reference

* fix: update lock file after merge

* fix: correct issue with mac-os tab completion

* chore: code review

* fix: integrate review feedback

* style: prettier

Co-authored-by: Armano <armano2@users.noreply.github.com>
  • Loading branch information
escapedcat and armano2 committed Nov 6, 2021
1 parent 42b3984 commit 5105f43
Show file tree
Hide file tree
Showing 16 changed files with 474 additions and 580 deletions.
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));
}

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);
}
}

0 comments on commit 5105f43

Please sign in to comment.