Skip to content

Commit 65769e9

Browse files
committed
chore: merge armano2 refactor/prompt2 branch
2 parents 4b3d125 + 40f8c67 commit 65769e9

16 files changed

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

@commitlint/prompt/package.json

+5-3
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,17 @@
3737
},
3838
"devDependencies": {
3939
"@commitlint/utils": "^13.0.0",
40-
"commitizen": "4.2.4"
40+
"@commitlint/types": "^13.0.0",
41+
"@commitlint/config-angular": "^13.0.0",
42+
"@types/inquirer": "^6.5.0",
43+
"commitizen": "^4.2.4"
4144
},
4245
"dependencies": {
4346
"@commitlint/load": "^13.1.0",
4447
"@commitlint/types": "^13.1.0",
4548
"chalk": "^4.0.0",
4649
"lodash": "^4.17.19",
47-
"throat": "^6.0.0",
48-
"vorpal": "^1.12.0"
50+
"inquirer": "^6.5.2"
4951
},
5052
"gitHead": "70f7f4688b51774e7ac5e40e896cdaa3f132b2bc"
5153
}

@commitlint/prompt/src/index.ts

+8-8
Original file line numberDiff line numberDiff line change
@@ -1,18 +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 {void}
1311
*/
14-
export function prompter(_: unknown, commit: Commit): void {
15-
input(vorpal).then((message) => {
16-
commit(message);
17-
});
12+
export async function prompter(
13+
cz: typeof inquirer,
14+
commit: Commit
15+
): Promise<void> {
16+
const message = await input(cz.prompt);
17+
commit(message);
1818
}

@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 {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,117 @@
1+
/// <reference path="./inquirer.d.ts" />
2+
import {Interface as ReadlineInterface, Key} from 'readline';
3+
4+
import chalk from 'chalk';
5+
import inquirer from 'inquirer';
6+
import InputPrompt from 'inquirer/lib/prompts/input';
7+
import observe from 'inquirer/lib/utils/events';
8+
import type {Subscription} from 'rxjs/internal/Subscription';
9+
10+
import Answers = inquirer.Answers;
11+
import InputCustomOptions = inquirer.InputCustomOptions;
12+
import SuccessfulPromptStateData = inquirer.prompts.SuccessfulPromptStateData;
13+
14+
interface KeyDescriptor {
15+
value: string;
16+
key: Key;
17+
}
18+
19+
export default class InputCustomPrompt<
20+
TQuestion extends InputCustomOptions = InputCustomOptions
21+
> extends InputPrompt<TQuestion> {
22+
private lineSubscription: Subscription;
23+
private readonly tabCompletion: string[];
24+
25+
constructor(
26+
question: TQuestion,
27+
readLine: ReadlineInterface,
28+
answers: Answers
29+
) {
30+
super(question, readLine, answers);
31+
32+
if (this.opt.log) {
33+
this.rl.write(this.opt.log(answers));
34+
}
35+
36+
if (!this.opt.maxLength) {
37+
this.throwParamError('maxLength');
38+
}
39+
40+
const events = observe(this.rl);
41+
this.lineSubscription = events.keypress.subscribe(
42+
this.onKeyPress2.bind(this)
43+
);
44+
this.tabCompletion = (this.opt.tabCompletion || [])
45+
.map((item) => item.value)
46+
.sort((a, b) => a.localeCompare(b));
47+
}
48+
49+
onEnd(state: SuccessfulPromptStateData): void {
50+
this.lineSubscription.unsubscribe();
51+
super.onEnd(state);
52+
}
53+
54+
/**
55+
* @see https://nodejs.org/api/readline.html#readline_rl_write_data_key
56+
* @see https://nodejs.org/api/readline.html#readline_rl_line
57+
*/
58+
updateLine(line: string): void {
59+
this.rl.write(null as any, {ctrl: true, name: 'b'});
60+
this.rl.write(null as any, {ctrl: true, name: 'd'});
61+
this.rl.write(line.substr(this.rl.line.length));
62+
}
63+
64+
onKeyPress2(e: KeyDescriptor): void {
65+
if (e.key.name === 'tab' && this.tabCompletion.length > 0) {
66+
let line = this.rl.line.trim();
67+
if (line.length > 0) {
68+
for (const item of this.tabCompletion) {
69+
if (item.startsWith(line) && item !== line) {
70+
line = item;
71+
break;
72+
}
73+
}
74+
}
75+
this.updateLine(line);
76+
}
77+
}
78+
79+
measureInput(input: string): number {
80+
if (this.opt.filter) {
81+
return this.opt.filter(input).length;
82+
}
83+
return input.length;
84+
}
85+
86+
render(error?: string): void {
87+
const answered = this.status === 'answered';
88+
89+
let bottomContent = '';
90+
let message = this.getQuestion();
91+
const length = this.measureInput(this.rl.line);
92+
93+
if (answered) {
94+
message += chalk.cyan(this.answer);
95+
} else if (this.opt.transformer) {
96+
message += this.opt.transformer(this.rl.line, this.answers, {});
97+
}
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)