Skip to content

Commit 02179c8

Browse files
committed
feat(prompt): rewrite codebase to use inquirer
1 parent 52696d0 commit 02179c8

14 files changed

+385
-568
lines changed

@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
@@ -36,6 +36,7 @@
3636
},
3737
"dependencies": {
3838
"@commitlint/prompt": "^11.0.0",
39+
"inquirer": "^6.5.2",
3940
"execa": "^5.0.0"
4041
},
4142
"gitHead": "cb565dfcca3128380b9b3dc274aedbcae34ce5ca"

@commitlint/prompt/package.json

+5-4
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,15 @@
3737
},
3838
"devDependencies": {
3939
"@commitlint/utils": "^11.0.0",
40-
"commitizen": "4.2.2"
40+
"@types/inquirer": "^6.5.0",
41+
"@commitlint/types": "^11.0.0",
42+
"commitizen": "4.2.2",
43+
"inquirer": "^6.5.2"
4144
},
4245
"dependencies": {
4346
"@commitlint/load": "^11.0.0",
4447
"chalk": "^4.0.0",
45-
"lodash": "^4.17.19",
46-
"throat": "^5.0.0",
47-
"vorpal": "^1.12.0"
48+
"lodash": "^4.17.19"
4849
},
4950
"gitHead": "cb565dfcca3128380b9b3dc274aedbcae34ce5ca"
5051
}

@commitlint/prompt/src/index.ts

+7-6
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
2-
// @ts-ignore
3-
import vorpal from 'vorpal';
1+
import inquirer from 'inquirer';
42
import input from './input';
53

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

86
/**
97
* Entry point for commitizen
10-
* @param _ inquirer instance passed by commitizen, unused
8+
* @param cz inquirer instance passed by commitizen
119
* @param commit callback to execute with complete commit message
1210
* @return generated commit message
1311
*/
14-
export async function prompter(_: unknown, commit: Commit): Promise<void> {
15-
const message = await input(vorpal);
12+
export async function prompter(
13+
cz: typeof inquirer,
14+
commit: Commit
15+
): Promise<void> {
16+
const message = await input(cz.prompt);
1617
commit(message);
1718
}

@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 {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 default 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 inputSettings: InputSetting = settings[input];
34+
const inputRules = getRules(input, rules);
35+
if (headerParts.includes(input) && maxLength < Infinity) {
36+
inputSettings.header = {
37+
length: maxLength,
38+
};
39+
}
40+
const question = getPrompt(input, inputRules, inputSettings);
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,150 @@
1+
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
2+
/// <reference path="./inquirer.d.ts" />
3+
import {Interface as ReadlineInterface, Key} from 'readline';
4+
5+
import chalk from 'chalk';
6+
import inquirer from 'inquirer';
7+
import InputPrompt from 'inquirer/lib/prompts/input';
8+
import observe from 'inquirer/lib/utils/events';
9+
import type {Subscription} from 'rxjs/internal/Subscription';
10+
11+
import Answers = inquirer.Answers;
12+
import InputCustomOptions = inquirer.InputCustomOptions;
13+
import Validator = inquirer.Validator;
14+
import SuccessfulPromptStateData = inquirer.prompts.SuccessfulPromptStateData;
15+
16+
interface KeyDescriptor {
17+
value: string;
18+
key: Key;
19+
}
20+
21+
export default class InputCustomPrompt<
22+
TQuestion extends InputCustomOptions = InputCustomOptions
23+
> extends InputPrompt<TQuestion> {
24+
private lineSubscription: Subscription;
25+
private readonly tabCompletion: string[];
26+
27+
constructor(
28+
question: TQuestion,
29+
readLine: ReadlineInterface,
30+
answers: Answers
31+
) {
32+
super(question, readLine, answers);
33+
34+
if (this.opt.log) {
35+
this.rl.write(this.opt.log(answers));
36+
}
37+
38+
if (!this.opt.maxLength) {
39+
this.throwParamError('maxLength');
40+
}
41+
42+
const events = observe(this.rl);
43+
this.lineSubscription = events.keypress.subscribe(
44+
this.onKeyPress2.bind(this)
45+
);
46+
this.tabCompletion = (this.opt.tabCompletion || [])
47+
.map((item) => item.value)
48+
.sort((a, b) => a.localeCompare(b));
49+
50+
this.opt.validate = this.extendedValidate(this.opt.validate);
51+
}
52+
53+
onEnd(state: SuccessfulPromptStateData): void {
54+
this.lineSubscription.unsubscribe();
55+
super.onEnd(state);
56+
}
57+
58+
extendedValidate(validate?: Validator<TQuestion>): Validator<TQuestion> {
59+
return (input, answers) => {
60+
if (input.length > this.opt.maxLength(answers)) {
61+
return 'Input contains too many characters!';
62+
}
63+
if (this.opt.required && input.trim().length === 0) {
64+
// Show help if enum is defined and input may not be empty
65+
return `⚠ ${chalk.bold(this.opt.name)} may not be empty.`;
66+
}
67+
68+
if (
69+
input.length > 0 &&
70+
this.tabCompletion.length > 0 &&
71+
!this.tabCompletion.includes(input)
72+
) {
73+
return `⚠ ${chalk.bold(
74+
this.opt.name
75+
)} must be one of ${this.tabCompletion.join(', ')}.`;
76+
}
77+
78+
if (validate) {
79+
return validate(input, answers);
80+
}
81+
return true;
82+
};
83+
}
84+
85+
/**
86+
* @see https://nodejs.org/api/readline.html#readline_rl_write_data_key
87+
* @see https://nodejs.org/api/readline.html#readline_rl_line
88+
*/
89+
updateLine(line: string): void {
90+
/* eslint-disable @typescript-eslint/ban-ts-comment */
91+
// @ts-ignore
92+
this.rl.line = line;
93+
// @ts-ignore
94+
this.rl.write(null, {ctrl: true, name: 'e'});
95+
}
96+
97+
onKeyPress2(e: KeyDescriptor): void {
98+
if (e.key.name === 'tab' && this.tabCompletion.length > 0) {
99+
let line = this.rl.line.trim();
100+
if (line.length > 0) {
101+
for (const item of this.tabCompletion) {
102+
if (item.startsWith(line) && item !== line) {
103+
line = item;
104+
break;
105+
}
106+
}
107+
}
108+
this.updateLine(line);
109+
}
110+
}
111+
112+
measureInput(input: string): number {
113+
if (this.opt.filter) {
114+
return this.opt.filter(input).length;
115+
}
116+
return input.length;
117+
}
118+
119+
render(error?: string): void {
120+
const answered = this.status === 'answered';
121+
122+
let bottomContent = '';
123+
let message = this.getQuestion();
124+
const length = this.measureInput(this.rl.line);
125+
126+
if (answered) {
127+
message += chalk.cyan(this.answer);
128+
} else if (this.opt.transformer) {
129+
message += this.opt.transformer(this.rl.line, this.answers, {});
130+
}
131+
132+
if (error) {
133+
bottomContent = chalk.red('>> ') + error;
134+
} else if (!answered) {
135+
const maxLength = this.opt.maxLength(this.answers);
136+
if (maxLength < Infinity) {
137+
const lengthRemaining = maxLength - length;
138+
const color =
139+
lengthRemaining <= 5
140+
? chalk.red
141+
: lengthRemaining <= 10
142+
? chalk.yellow
143+
: chalk.grey;
144+
bottomContent = color(`${lengthRemaining} characters left`);
145+
}
146+
}
147+
148+
this.screen.render(message, bottomContent);
149+
}
150+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {Answers, InputQuestionOptions} from 'inquirer';
2+
3+
declare module 'inquirer' {
4+
interface InputCustomCompletionOption {
5+
value: string;
6+
description?: string;
7+
}
8+
9+
export interface InputCustomOptions<T extends Answers = Answers>
10+
extends InputQuestionOptions<T> {
11+
/**
12+
* @inheritdoc
13+
*/
14+
type?: 'input-custom';
15+
required?: boolean;
16+
log?(answers?: T): string;
17+
tabCompletion?: InputCustomCompletionOption[];
18+
maxLength(answers?: T): number;
19+
}
20+
21+
interface QuestionMap<T extends Answers = Answers> {
22+
'input-custom': InputCustomOptions<T>;
23+
}
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {Result} from './types';
2+
import format from './format';
3+
4+
test('should return empty string', () => {
5+
const result: Result = {};
6+
expect(format(result)).toBe(' ');
7+
});
8+
9+
test('should omit scope', () => {
10+
const result: Result = {
11+
type: 'fix',
12+
subject: 'test',
13+
};
14+
expect(format(result)).toBe('fix: test');
15+
});
16+
17+
test('should include scope', () => {
18+
const result: Result = {
19+
type: 'fix',
20+
scope: 'prompt',
21+
subject: 'test',
22+
};
23+
expect(format(result)).toBe('fix(prompt): test');
24+
});
25+
26+
test('should include body', () => {
27+
const result: Result = {
28+
type: 'fix',
29+
scope: 'prompt',
30+
subject: 'test',
31+
body: 'some body',
32+
};
33+
expect(format(result)).toBe('fix(prompt): test\nsome body');
34+
});
35+
36+
test('should include footer', () => {
37+
const result: Result = {
38+
type: 'fix',
39+
scope: 'prompt',
40+
subject: 'test',
41+
footer: 'some footer',
42+
};
43+
expect(format(result)).toBe('fix(prompt): test\nsome footer');
44+
});
45+
46+
test('should include body and footer', () => {
47+
const result: Result = {
48+
type: 'fix',
49+
scope: 'prompt',
50+
subject: 'test',
51+
body: 'some body',
52+
footer: 'some footer',
53+
};
54+
expect(format(result)).toBe('fix(prompt): test\nsome body\nsome footer');
55+
});

0 commit comments

Comments
 (0)