Skip to content

Commit

Permalink
feat(prompt): rewrite codebase to use inquirer
Browse files Browse the repository at this point in the history
  • Loading branch information
armano2 committed Jan 4, 2021
1 parent 8c41651 commit 8d7f369
Show file tree
Hide file tree
Showing 14 changed files with 385 additions and 568 deletions.
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 @@ -36,6 +36,7 @@
},
"dependencies": {
"@commitlint/prompt": "^11.0.0",
"inquirer": "^6.5.2",
"execa": "^5.0.0"
},
"gitHead": "cb565dfcca3128380b9b3dc274aedbcae34ce5ca"
Expand Down
9 changes: 5 additions & 4 deletions @commitlint/prompt/package.json
Expand Up @@ -37,14 +37,15 @@
},
"devDependencies": {
"@commitlint/utils": "^11.0.0",
"commitizen": "4.2.2"
"@types/inquirer": "^6.5.0",
"@commitlint/types": "^11.0.0",
"commitizen": "4.2.2",
"inquirer": "^6.5.2"
},
"dependencies": {
"@commitlint/load": "^11.0.0",
"chalk": "^4.0.0",
"lodash": "^4.17.19",
"throat": "^5.0.0",
"vorpal": "^1.12.0"
"lodash": "^4.17.19"
},
"gitHead": "cb565dfcca3128380b9b3dc274aedbcae34ce5ca"
}
13 changes: 7 additions & 6 deletions @commitlint/prompt/src/index.ts
@@ -1,17 +1,18 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import vorpal from 'vorpal';
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 cz inquirer instance passed by commitizen
* @param commit callback to execute with complete commit message
* @return generated commit message
*/
export async function prompter(_: unknown, commit: Commit): Promise<void> {
const message = await input(vorpal);
export async function prompter(
cz: typeof inquirer,
commit: Commit
): Promise<void> {
const message = await input(cz.prompt);
commit(message);
}
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 {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 default 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 inputSettings: InputSetting = settings[input];
const inputRules = getRules(input, rules);
if (headerParts.includes(input) && maxLength < Infinity) {
inputSettings.header = {
length: maxLength,
};
}
const question = getPrompt(input, inputRules, inputSettings);
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);
}
}
150 changes: 150 additions & 0 deletions @commitlint/prompt/src/inquirer/InputCustomPrompt.ts
@@ -0,0 +1,150 @@
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
/// <reference path="./inquirer.d.ts" />
import {Interface as ReadlineInterface, Key} from 'readline';

import chalk from 'chalk';
import inquirer from 'inquirer';
import InputPrompt from 'inquirer/lib/prompts/input';
import observe from 'inquirer/lib/utils/events';
import type {Subscription} from 'rxjs/internal/Subscription';

import Answers = inquirer.Answers;
import InputCustomOptions = inquirer.InputCustomOptions;
import Validator = inquirer.Validator;
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));

this.opt.validate = this.extendedValidate(this.opt.validate);
}

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

extendedValidate(validate?: Validator<TQuestion>): Validator<TQuestion> {
return (input, answers) => {
if (input.length > this.opt.maxLength(answers)) {
return 'Input contains too many characters!';
}
if (this.opt.required && input.trim().length === 0) {
// Show help if enum is defined and input may not be empty
return `⚠ ${chalk.bold(this.opt.name)} may not be empty.`;
}

if (
input.length > 0 &&
this.tabCompletion.length > 0 &&
!this.tabCompletion.includes(input)
) {
return `⚠ ${chalk.bold(
this.opt.name
)} must be one of ${this.tabCompletion.join(', ')}.`;
}

if (validate) {
return validate(input, answers);
}
return true;
};
}

/**
* @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 {
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-ignore
this.rl.line = line;
// @ts-ignore
this.rl.write(null, {ctrl: true, name: 'e'});
}

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 bottomContent = '';
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, {});
}

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);
}
}
24 changes: 24 additions & 0 deletions @commitlint/prompt/src/inquirer/inquirer.d.ts
@@ -0,0 +1,24 @@
import {Answers, InputQuestionOptions} from 'inquirer';

declare module 'inquirer' {
interface InputCustomCompletionOption {
value: string;
description?: string;
}

export interface InputCustomOptions<T extends Answers = Answers>
extends InputQuestionOptions<T> {
/**
* @inheritdoc
*/
type?: 'input-custom';
required?: boolean;
log?(answers?: T): string;
tabCompletion?: InputCustomCompletionOption[];
maxLength(answers?: T): number;
}

interface QuestionMap<T extends Answers = Answers> {
'input-custom': InputCustomOptions<T>;
}
}
55 changes: 55 additions & 0 deletions @commitlint/prompt/src/library/format.test.ts
@@ -0,0 +1,55 @@
import {Result} from './types';
import format from './format';

test('should return empty string', () => {
const result: Result = {};
expect(format(result)).toBe(' ');
});

test('should omit scope', () => {
const result: Result = {
type: 'fix',
subject: 'test',
};
expect(format(result)).toBe('fix: test');
});

test('should include scope', () => {
const result: Result = {
type: 'fix',
scope: 'prompt',
subject: 'test',
};
expect(format(result)).toBe('fix(prompt): test');
});

test('should include body', () => {
const result: Result = {
type: 'fix',
scope: 'prompt',
subject: 'test',
body: 'some body',
};
expect(format(result)).toBe('fix(prompt): test\nsome body');
});

test('should include footer', () => {
const result: Result = {
type: 'fix',
scope: 'prompt',
subject: 'test',
footer: 'some footer',
};
expect(format(result)).toBe('fix(prompt): test\nsome footer');
});

test('should include body and footer', () => {
const result: Result = {
type: 'fix',
scope: 'prompt',
subject: 'test',
body: 'some body',
footer: 'some footer',
};
expect(format(result)).toBe('fix(prompt): test\nsome body\nsome footer');
});

0 comments on commit 8d7f369

Please sign in to comment.