Skip to content

Commit

Permalink
extract methods
Browse files Browse the repository at this point in the history
  • Loading branch information
mshima committed Sep 17, 2024
1 parent f7dfeef commit f818c27
Showing 1 changed file with 187 additions and 148 deletions.
335 changes: 187 additions & 148 deletions packages/inquirer/src/ui/prompt.mts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ export type PromptFn<Value = any, Config = any> = (
*/
export type PromptCollection = Record<string, PromptFn | LegacyPromptConstructor>;

type ResolvedQuestion<A extends Answers, Type extends string = string> = AnyQuestion<
A,
Type
> & {
message: string;
default?: any;
choices?: any;
};

class TTYError extends Error {
override name = 'TTYError';
isTtyError = true;
Expand Down Expand Up @@ -186,6 +195,138 @@ function isPromptConstructor(
);
}

async function shouldRun<A extends Answers>(
question: AnyQuestion<A>,
answers: Partial<A>,
): Promise<boolean> {
if (question.askAnswered !== true && answers[question.name] !== undefined) {
return false;
}

const { when } = question;
if (typeof when === 'function') {
const shouldRun = await runAsync(when)(answers);
return Boolean(shouldRun);
}

return when !== false;
}

function createLegacyPromptFn<A extends Answers>(
prompt: LegacyPromptConstructor,
answers: Partial<A>,
): PromptFn<A> {
return (q, opt) =>
new Promise<A>((resolve, reject) => {
let cleanupSignal: (() => void) | undefined;

const { signal } = opt;
if (signal.aborted) {
reject(new AbortPromptError({ cause: signal.reason }));
return;
}

const rl = readline.createInterface(setupReadlineOptions(opt)) as InquirerReadline;

const abort = () => {
reject(new AbortPromptError({ cause: signal.reason }));
cleanup();
};
/**
* Handle the ^C exit
*/
const onForceClose = () => {
abort();
process.kill(process.pid, 'SIGINT');
console.log('');
};

Check warning on line 242 in packages/inquirer/src/ui/prompt.mts

View check run for this annotation

Codecov / codecov/patch

packages/inquirer/src/ui/prompt.mts#L239-L242

Added lines #L239 - L242 were not covered by tests

const onClose = () => {
process.removeListener('exit', onForceClose);
rl.removeListener('SIGINT', onForceClose);
rl.setPrompt('');
rl.output.unmute();
rl.output.write(ansiEscapes.cursorShow);
rl.output.end();
rl.close();
};

// Make sure new prompt start on a newline when closing
process.on('exit', onForceClose);
rl.on('SIGINT', onForceClose);

const activePrompt = new prompt(q, rl, answers);

const cleanup = () => {
onClose();
cleanupSignal?.();
};

signal.addEventListener('abort', abort);
cleanupSignal = () => {
signal.removeEventListener('abort', abort);
cleanupSignal = undefined;
};

activePrompt.run().then(resolve, reject).finally(cleanup);
});
}

async function prepareQuestion<A extends Answers>(
question: AnyQuestion<A>,
answers: Partial<A>,
) {
const [message, defaultValue, resolvedChoices] = await Promise.all([
fetchAsyncQuestionProperty(question, 'message', answers),
fetchAsyncQuestionProperty(question, 'default', answers),
fetchAsyncQuestionProperty(question, 'choices', answers),
]);

let choices;
if (Array.isArray(resolvedChoices)) {
choices = resolvedChoices.map((choice: unknown) => {
if (typeof choice === 'string' || typeof choice === 'number') {
return { name: choice, value: choice };
} else if (
typeof choice === 'object' &&
choice != null &&
!('value' in choice) &&
'name' in choice
) {
return { ...choice, value: choice.name };
}
return choice;

Check warning on line 298 in packages/inquirer/src/ui/prompt.mts

View check run for this annotation

Codecov / codecov/patch

packages/inquirer/src/ui/prompt.mts#L291-L298

Added lines #L291 - L298 were not covered by tests
});
}

return Object.assign({}, question, {
message,
default: defaultValue,
choices,
});
}

async function fetchAnswer<A extends Answers>(
prompt: PromptFn<any, any> | LegacyPromptConstructor | undefined,
question: ResolvedQuestion<A>,
answers: Partial<A>,
context: StreamOptions & { signal: AbortSignal },
) {
if (prompt == null) {
throw new Error(`Prompt for type ${question.type} not found`);
}

Check warning on line 317 in packages/inquirer/src/ui/prompt.mts

View check run for this annotation

Codecov / codecov/patch

packages/inquirer/src/ui/prompt.mts#L316-L317

Added lines #L316 - L317 were not covered by tests

const promptFn: PromptFn<A> = isPromptConstructor(prompt)
? createLegacyPromptFn(prompt, answers)
: prompt;

const { filter = (value) => value } = question;
return promptFn(question, context).then((answer: unknown) => ({
name: question.name,
answer: filter(answer, answers),
}));
}

/**
* Base interface class other can inherits from
*/
Expand Down Expand Up @@ -227,6 +368,18 @@ export default class PromptsRunner<A extends Answers> {
): Promise<A> {
this.abortController = new AbortController();

let cleanupModuleSignal: (() => void) | undefined;
const { signal: moduleSignal } = this.opt;
if (moduleSignal?.aborted) {
this.abortController.abort(moduleSignal.reason);
} else if (moduleSignal) {
const abort = () => this.abortController?.abort(moduleSignal.reason);
moduleSignal.addEventListener('abort', abort);
cleanupModuleSignal = () => {
moduleSignal.removeEventListener('abort', abort);
};
}

let obs: Observable<AnyQuestion<A>>;
if (isQuestionArray(questions)) {
obs = from(questions);
Expand All @@ -252,172 +405,58 @@ export default class PromptsRunner<A extends Answers> {
.pipe(
concatMap((question) =>
from(
this.shouldRun(question).then((shouldRun: boolean | void) => {
if (shouldRun) {
return question;
}
return;
}),
shouldRun<A>(question, this.answers).then(
(shouldRun: boolean | void) => {
if (shouldRun) {
return question;
}
return;
},
),
).pipe(filter((val) => val != null)),
),
concatMap((question) => defer(() => from(this.fetchAnswer(question)))),
concatMap((question) =>
defer(() =>
from(
prepareQuestion(question, this.answers).then((question) =>
fetchAnswer(
this.prompts[question.type] ?? this.prompts['input'],
question,
this.answers,
{ ...this.opt, signal: this.abortController.signal },
),
),
),
),
),
)
.pipe(tap((answer) => this.process.next(answer))),
),
)
.pipe(
reduce((answersObj: Record<string, any>, answer: { name: string; answer: unknown }) => {
answersObj[answer.name] = answer.answer;
return answersObj;
}, this.answers),
reduce(
(
answersObj: Record<string, any>,
answer: { name: string; answer: unknown },
) => {
answersObj[answer.name] = answer.answer;
return answersObj;
},
this.answers,
),
),
)
.then(() => this.answers as A)
.finally(() => this.close());
}

private prepareQuestion = async (question: AnyQuestion<A>) => {
const [message, defaultValue, resolvedChoices] = await Promise.all([
fetchAsyncQuestionProperty(question, 'message', this.answers),
fetchAsyncQuestionProperty(question, 'default', this.answers),
fetchAsyncQuestionProperty(question, 'choices', this.answers),
]);

let choices;
if (Array.isArray(resolvedChoices)) {
choices = resolvedChoices.map((choice: unknown) => {
if (typeof choice === 'string' || typeof choice === 'number') {
return { name: choice, value: choice };
} else if (
typeof choice === 'object' &&
choice != null &&
!('value' in choice) &&
'name' in choice
) {
return { ...choice, value: choice.name };
}
return choice;
});
}

return Object.assign({}, question, {
message,
default: defaultValue,
choices,
type: question.type in this.prompts ? question.type : 'input',
});
};

private fetchAnswer = async (rawQuestion: AnyQuestion<A>) => {
const question = await this.prepareQuestion(rawQuestion);
const prompt = this.prompts[question.type];

if (prompt == null) {
throw new Error(`Prompt for type ${question.type} not found`);
}

let cleanupSignal: (() => void) | undefined;

const promptFn: PromptFn<A> = isPromptConstructor(prompt)
? (q, opt) =>
new Promise<A>((resolve, reject) => {
const { signal } = opt;
if (signal.aborted) {
reject(new AbortPromptError({ cause: signal.reason }));
return;
}

const rl = readline.createInterface(
setupReadlineOptions(opt),
) as InquirerReadline;

/**
* Handle the ^C exit
*/
const onForceClose = () => {
this.close();
process.kill(process.pid, 'SIGINT');
console.log('');
};

const onClose = () => {
process.removeListener('exit', onForceClose);
rl.removeListener('SIGINT', onForceClose);
rl.setPrompt('');
rl.output.unmute();
rl.output.write(ansiEscapes.cursorShow);
rl.output.end();
rl.close();
};

// Make sure new prompt start on a newline when closing
process.on('exit', onForceClose);
rl.on('SIGINT', onForceClose);

const activePrompt = new prompt(q, rl, this.answers);

const cleanup = () => {
onClose();
cleanupSignal?.();
};

const abort = () => {
reject(new AbortPromptError({ cause: signal.reason }));
cleanup();
};
signal.addEventListener('abort', abort);
cleanupSignal = () => {
signal.removeEventListener('abort', abort);
cleanupSignal = undefined;
};

activePrompt.run().then(resolve, reject).finally(cleanup);
})
: prompt;

let cleanupModuleSignal: (() => void) | undefined;
const { signal: moduleSignal } = this.opt;
if (moduleSignal?.aborted) {
this.abortController.abort(moduleSignal.reason);
} else if (moduleSignal) {
const abort = () => this.abortController?.abort(moduleSignal.reason);
moduleSignal.addEventListener('abort', abort);
cleanupModuleSignal = () => {
moduleSignal.removeEventListener('abort', abort);
};
}

const { filter = (value) => value } = question;
const { signal } = this.abortController;
return promptFn(question, { ...this.opt, signal })
.then((answer: unknown) => ({
name: question.name,
answer: filter(answer, this.answers),
}))
.finally(() => {
cleanupSignal?.();
cleanupModuleSignal?.();
this.close();
});
};
}

/**
* Close the interface and cleanup listeners
*/
close = () => {
this.abortController?.abort();
};

private shouldRun = async (question: AnyQuestion<A>): Promise<boolean> => {
if (question.askAnswered !== true && this.answers[question.name] !== undefined) {
return false;
}

const { when } = question;
if (typeof when === 'function') {
const shouldRun = await runAsync(when)(this.answers);
return Boolean(shouldRun);
}

return when !== false;
};
}

0 comments on commit f818c27

Please sign in to comment.