Skip to content

Commit 5105f43

Browse files
escapedcatarmano2
andauthored
feat(prompt): rewrite codebase to use inquirer - UPDATED with current 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 <[email protected]>
1 parent 42b3984 commit 5105f43

16 files changed

+474
-580
lines changed

.eslintrc.js

+1
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ module.exports = {
5252
'@typescript-eslint/no-var-requires': 'off',
5353
'@typescript-eslint/no-inferrable-types': 'off',
5454
'@typescript-eslint/no-non-null-assertion': 'off',
55+
'@typescript-eslint/triple-slash-reference': 'off',
5556

5657
// TODO: enable those rules?
5758
'no-empty': 'off',

@commitlint/prompt-cli/cli.js

+2-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
#!/usr/bin/env node
22
const execa = require('execa');
3+
const inquirer = require('inquirer');
34
const {prompter} = require('@commitlint/prompt');
45

5-
const _ = undefined;
6-
const prompt = () => prompter(_, commit);
7-
86
main().catch((err) => {
97
setTimeout(() => {
108
throw err;
@@ -21,7 +19,7 @@ function main() {
2119
process.exit(1);
2220
}
2321
})
24-
.then(() => prompt());
22+
.then(() => prompter(inquirer, commit));
2523
}
2624

2725
function isStageEmpty() {

@commitlint/prompt-cli/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
},
3838
"dependencies": {
3939
"@commitlint/prompt": "^14.1.0",
40+
"inquirer": "^6.5.2",
4041
"execa": "^5.0.0"
4142
},
4243
"gitHead": "70f7f4688b51774e7ac5e40e896cdaa3f132b2bc"

@commitlint/prompt/package.json

+7-3
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,19 @@
3838
},
3939
"devDependencies": {
4040
"@commitlint/utils": "^14.0.0",
41-
"commitizen": "4.2.4"
41+
"@commitlint/types": "^13.2.0",
42+
"@commitlint/config-angular": "^13.2.0",
43+
"@types/inquirer": "^6.5.0",
44+
"inquirer": "^6.5.2",
45+
"commitizen": "^4.2.4"
4246
},
4347
"dependencies": {
4448
"@commitlint/ensure": "^14.1.0",
4549
"@commitlint/load": "^14.1.0",
4650
"@commitlint/types": "^14.0.0",
4751
"chalk": "^4.0.0",
48-
"throat": "^6.0.0",
49-
"vorpal": "^1.12.0"
52+
"lodash": "^4.17.19",
53+
"inquirer": "^6.5.2"
5054
},
5155
"gitHead": "70f7f4688b51774e7ac5e40e896cdaa3f132b2bc"
5256
}

@commitlint/prompt/src/index.ts

+4-12
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,10 @@
1-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
2-
// @ts-ignore
3-
import vorpal from 'vorpal';
4-
import input from './input';
1+
import inquirer from 'inquirer';
2+
import {input} from './input';
53

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

8-
/**
9-
* Entry point for commitizen
10-
* @param _ inquirer instance passed by commitizen, unused
11-
* @param commit callback to execute with complete commit message
12-
* @return {void}
13-
*/
14-
export function prompter(_: unknown, commit: Commit): void {
15-
input(vorpal).then((message) => {
6+
export function prompter(cz: typeof inquirer, commit: Commit): void {
7+
input(cz.prompt).then((message) => {
168
commit(message);
179
});
1810
}

@commitlint/prompt/src/input.test.ts

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {Answers, PromptModule, QuestionCollection} from 'inquirer';
2+
/// <reference path="./inquirer/inquirer.d.ts" />
3+
import {input} from './input';
4+
import chalk from 'chalk';
5+
6+
jest.mock(
7+
'@commitlint/load',
8+
() => {
9+
return () => require('@commitlint/config-angular');
10+
},
11+
{
12+
virtual: true,
13+
}
14+
);
15+
16+
test('should work with all fields filled', async () => {
17+
const prompt = stub({
18+
'input-custom': {
19+
type: 'fix',
20+
scope: 'test',
21+
subject: 'subject',
22+
body: 'body',
23+
footer: 'footer',
24+
},
25+
});
26+
const message = await input(prompt);
27+
expect(message).toEqual('fix(test): subject\n' + 'body\n' + 'footer');
28+
});
29+
30+
test('should work without scope', async () => {
31+
const prompt = stub({
32+
'input-custom': {
33+
type: 'fix',
34+
scope: '',
35+
subject: 'subject',
36+
body: 'body',
37+
footer: 'footer',
38+
},
39+
});
40+
const message = await input(prompt);
41+
expect(message).toEqual('fix: subject\n' + 'body\n' + 'footer');
42+
});
43+
44+
test('should fail without type', async () => {
45+
const spy = jest.spyOn(console, 'error').mockImplementation();
46+
const prompt = stub({
47+
'input-custom': {
48+
type: '',
49+
scope: '',
50+
subject: '',
51+
body: '',
52+
footer: '',
53+
},
54+
});
55+
const message = await input(prompt);
56+
expect(message).toEqual('');
57+
expect(console.error).toHaveBeenCalledTimes(1);
58+
expect(console.error).toHaveBeenLastCalledWith(
59+
new Error(`⚠ ${chalk.bold('type')} may not be empty.`)
60+
);
61+
spy.mockRestore();
62+
});
63+
64+
function stub(config: Record<string, Record<string, unknown>>): PromptModule {
65+
const prompt = async (questions: QuestionCollection): Promise<any> => {
66+
const result: Answers = {};
67+
const resolvedConfig = Array.isArray(questions) ? questions : [questions];
68+
for (const promptConfig of resolvedConfig) {
69+
const configType = promptConfig.type || 'input';
70+
const questions = config[configType];
71+
if (!questions) {
72+
throw new Error(`Unexpected config type: ${configType}`);
73+
}
74+
const answer = questions[promptConfig.name!];
75+
if (answer == null) {
76+
throw new Error(`Unexpected config name: ${promptConfig.name}`);
77+
}
78+
const validate = promptConfig.validate;
79+
if (validate) {
80+
const validationResult = validate(answer, result);
81+
if (validationResult !== true) {
82+
throw new Error(validationResult || undefined);
83+
}
84+
}
85+
86+
result[promptConfig.name!] = answer;
87+
}
88+
return result;
89+
};
90+
prompt.registerPrompt = () => {
91+
return prompt;
92+
};
93+
prompt.restoreDefaultPrompts = () => true;
94+
prompt.prompts = {};
95+
return prompt as any as PromptModule;
96+
}

@commitlint/prompt/src/input.ts

+27-38
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,21 @@
11
import load from '@commitlint/load';
2-
import throat from 'throat';
2+
import {DistinctQuestion, PromptModule} from 'inquirer';
33

44
import format from './library/format';
55
import getPrompt from './library/get-prompt';
66
import settings from './settings';
7-
import {InputSetting, Prompter, Result} from './library/types';
8-
import {getHasName, getMaxLength, getRules} from './library/utils';
7+
import type {InputSetting, Result} from './library/types';
98

10-
export default input;
9+
import {getHasName, getMaxLength, getRules} from './library/utils';
10+
import InputCustomPrompt from './inquirer/InputCustomPrompt';
1111

1212
/**
1313
* Get user input by interactive prompt based on
1414
* conventional-changelog-lint rules.
1515
* @param prompter
1616
* @return commit message
1717
*/
18-
async function input(prompter: () => Prompter): Promise<string> {
19-
const results: Result = {
20-
type: null,
21-
scope: null,
22-
subject: null,
23-
body: null,
24-
footer: null,
25-
};
26-
18+
export async function input(prompter: PromptModule): Promise<string> {
2719
const {rules} = await load();
2820
const parts = ['type', 'scope', 'subject', 'body', 'footer'] as const;
2921
const headerParts = ['type', 'scope', 'subject'];
@@ -33,31 +25,28 @@ async function input(prompter: () => Prompter): Promise<string> {
3325
);
3426
const maxLength = getMaxLength(headerLengthRule);
3527

36-
await Promise.all(
37-
parts.map(
38-
throat(1, async (input) => {
39-
const inputRules = getRules(input, rules);
40-
const inputSettings: InputSetting = settings[input];
41-
42-
if (headerParts.includes(input) && maxLength < Infinity) {
43-
inputSettings.header = {
44-
length: maxLength,
45-
};
46-
}
47-
48-
results[input] = await getPrompt(input, {
49-
rules: inputRules,
50-
settings: inputSettings,
51-
results,
52-
prompter,
53-
});
54-
})
55-
)
56-
).catch((err) => {
28+
try {
29+
const questions: DistinctQuestion<Result>[] = [];
30+
prompter.registerPrompt('input-custom', InputCustomPrompt);
31+
32+
for (const input of parts) {
33+
const inputSetting: InputSetting = settings[input];
34+
const inputRules = getRules(input, rules);
35+
if (headerParts.includes(input) && maxLength < Infinity) {
36+
inputSetting.header = {
37+
length: maxLength,
38+
};
39+
}
40+
const question = getPrompt(input, inputRules, inputSetting);
41+
if (question) {
42+
questions.push(question);
43+
}
44+
}
45+
46+
const results = await prompter<Result>(questions);
47+
return format(results);
48+
} catch (err) {
5749
console.error(err);
5850
return '';
59-
});
60-
61-
// Return the results
62-
return format(results);
51+
}
6352
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/// <reference path="./inquirer.d.ts" />
2+
import chalk from 'chalk';
3+
import inquirer from 'inquirer';
4+
import InputPrompt from 'inquirer/lib/prompts/input';
5+
import observe from 'inquirer/lib/utils/events';
6+
import {Interface as ReadlineInterface, Key} from 'readline';
7+
import type {Subscription} from 'rxjs/internal/Subscription';
8+
9+
import Answers = inquirer.Answers;
10+
import InputCustomOptions = inquirer.InputCustomOptions;
11+
import SuccessfulPromptStateData = inquirer.prompts.SuccessfulPromptStateData;
12+
13+
interface KeyDescriptor {
14+
value: string;
15+
key: Key;
16+
}
17+
18+
export default class InputCustomPrompt<
19+
TQuestion extends InputCustomOptions = InputCustomOptions
20+
> extends InputPrompt<TQuestion> {
21+
private lineSubscription: Subscription;
22+
private readonly tabCompletion: string[];
23+
24+
constructor(
25+
question: TQuestion,
26+
readLine: ReadlineInterface,
27+
answers: Answers
28+
) {
29+
super(question, readLine, answers);
30+
31+
if (this.opt.log) {
32+
this.rl.write(this.opt.log(answers));
33+
}
34+
35+
if (!this.opt.maxLength) {
36+
this.throwParamError('maxLength');
37+
}
38+
39+
const events = observe(this.rl);
40+
this.lineSubscription = events.keypress.subscribe(
41+
this.onKeyPress2.bind(this)
42+
);
43+
this.tabCompletion = (this.opt.tabCompletion || [])
44+
.map((item) => item.value)
45+
.sort((a, b) => a.localeCompare(b));
46+
}
47+
48+
onEnd(state: SuccessfulPromptStateData): void {
49+
this.lineSubscription.unsubscribe();
50+
super.onEnd(state);
51+
}
52+
53+
/**
54+
* @see https://nodejs.org/api/readline.html#readline_rl_write_data_key
55+
* @see https://nodejs.org/api/readline.html#readline_rl_line
56+
*/
57+
updateLine(line: string): void {
58+
this.rl.write(null as any, {ctrl: true, name: 'b'});
59+
this.rl.write(null as any, {ctrl: true, name: 'd'});
60+
this.rl.write(line.substr(this.rl.line.length));
61+
}
62+
63+
onKeyPress2(e: KeyDescriptor): void {
64+
if (e.key.name === 'tab' && this.tabCompletion.length > 0) {
65+
let line = this.rl.line.trim();
66+
if (line.length > 0) {
67+
for (const item of this.tabCompletion) {
68+
if (item.startsWith(line) && item !== line) {
69+
line = item;
70+
break;
71+
}
72+
}
73+
}
74+
this.updateLine(line);
75+
}
76+
}
77+
78+
measureInput(input: string): number {
79+
if (this.opt.filter) {
80+
return this.opt.filter(input).length;
81+
}
82+
return input.length;
83+
}
84+
85+
render(error?: string): void {
86+
const answered = this.status === 'answered';
87+
88+
let message = this.getQuestion();
89+
const length = this.measureInput(this.rl.line);
90+
91+
if (answered) {
92+
message += chalk.cyan(this.answer);
93+
} else if (this.opt.transformer) {
94+
message += this.opt.transformer(this.rl.line, this.answers, {});
95+
}
96+
97+
let bottomContent = '';
98+
99+
if (error) {
100+
bottomContent = chalk.red('>> ') + error;
101+
} else if (!answered) {
102+
const maxLength = this.opt.maxLength(this.answers);
103+
if (maxLength < Infinity) {
104+
const lengthRemaining = maxLength - length;
105+
const color =
106+
lengthRemaining <= 5
107+
? chalk.red
108+
: lengthRemaining <= 10
109+
? chalk.yellow
110+
: chalk.grey;
111+
bottomContent = color(`${lengthRemaining} characters left`);
112+
}
113+
}
114+
115+
this.screen.render(message, bottomContent);
116+
}
117+
}

0 commit comments

Comments
 (0)