console/index.js

/**
 * Console provides utilities for interacting with a text console.
 * {@link Console#readInt}, {@link Console#readFloat}, {@link Console#readBoolean}, and {@link Console#readLine}
 * prompt the user for input and parse it to the corresponding type. This prompt will use the blocking
 * browser prompt by default, but can be configured using {@link Console#onInput}.
 *
 * Console also exposes {@link Console#print} and {@link Console#println}, which are used for
 * emitting output. By default the output will print to the console, but can be configured using
 * {@link Console#onOutput}.
 */
class Console {
    /**
     * Function invoked when asking for asynchronous user input with the read*Async functions.
     * This function is invoked with the string of the prompt, i.e. readIntAsync('give me an int!').
     * The result of invoking onPrompt will be awaited, then parsed to configrm it's the
     * appropriate data type (a float, in the case of readFloat, for example). If
     * onPrompt is undefined, window.prompt is used as a fallback.
     * @type {Function}
     */
    onInput = async promptString => await prompt(promptString);
    /**
     * Function invoked when printing.
     * This function is invoked with any output, either in the case of explicit calls to `print`
     * or `println` or internal calls within the library. If onPrint is undefined, console.log
     * is used as a fallback.
     * @type {function}
     */
    onOutput = window.console.log.bind(window.console);
    /**
     * Function invoked when {@link Console#clear} is called.
     */
    onClear = window.console.clear.bind(window.console);

    /**
     * Initialize the console class, additionally configuring any event handlers.
     * @constructor
     * @param {Object} options
     * @param {function} options.input Function invoked when asking for user input asynchronously.
     * This function is invoked with the string of the prompt, i.e. readIntAsync('give me an int!').
     * The result of invoking onPrompt will be awaited, then parsed to configrm it's the
     * appropriate data type (a float, in the case of readFloat, for example). If
     * onPrompt is undefined, window.prompt is used as a fallback.
     * @param {function} options.output Function invoked when printing.
     * This function is invoked with any output, either in the case of explicit calls to `print`
     * or `println` or internal calls within the library. If onPrint is undefined, console.log
     * is used as a fallback.
     * @param {function} options.clear Function invoked when clear() is called.
     * @param {function} options.prompt Function that transforms the prompt string to a function like `readInt` before it is passed to `prompt`.
     */
    constructor(options = {}) {
        this.onInput = options.input ?? (async promptString => await prompt(promptString));
        this.onOutput = options.output ?? window.console.log.bind(window.console);
        this.onClear = options.clear ?? window.console.clear.bind(window.console);
        this.promptTransform =
            options.prompt ??
            ((promptString, defaultValue) => {
                return promptString;
            });
    }

    /**
     * Configure the Console instance, providing methods it invokes
     * when prompting for input and emitting output.
     *
     * @param {Object} options
     * @param {function} options.input Function invoked when asking for user input asynchronously.
     * This function is invoked with the string of the prompt, i.e. readIntAsync('give me an int!').
     * The result of invoking onPrompt will be awaited, then parsed to configrm it's the
     * appropriate data type (a float, in the case of readFloat, for example). If
     * onPrompt is undefined, window.prompt is used as a fallback.
     * @param {function} options.output Function invoked when printing.
     * This function is invoked with any output, either in the case of explicit calls to `print`
     * or `println` or internal calls within the library. If onPrint is undefined, console.log
     * is used as a fallback.
     * @param {function} options.clear Function invoked when clear() is called.
     * @param {function} options.prompt Function that transforms the prompt string to a function like `readInt` before it is passed to `prompt`.
     */
    configure(options = {}) {
        this.onInput = options.input ?? this.onInput;
        this.onOutput = options.output ?? this.onOutput;
        this.onClear = options.clear ?? this.onClear;
        this.promptTransform = options.prompt ?? this.promptTransform;
    }

    /**
     * Private method used to read a line.
     * @param {string} promptString - The line to be printed before prompting.
     */
    readLinePrivate(promptString) {
        const input = prompt(this.promptTransform(promptString));
        return input;
    }

    /**
     * Private method used to read a line using the read*Async methods.
     * @param {string} promptString - The line to be printed before prompting.
     */
    readLinePrivateAsync(promptString) {
        const input = this.onInput(promptString);
        return input;
    }

    /**
     * Clear the console.
     * @global
     */
    clear() {
        this.onClear();
    }

    /**
     * Print a value to the console.
     * @param {...any} args - Anything to print.
     * @global
     */
    print(...args) {
        if (args.length < 1) {
            throw new Error('You should pass at least 1 argument to print');
        }
        this.onOutput(...args);
    }

    /**
     * Print a value to the console, followed by a newline character.
     * @param {any} value - The value to print.
     * @global
     */
    println(value) {
        if (arguments.length === 0) {
            value = '';
        } else if (arguments.length !== 1) {
            throw new Error('You should pass exactly 1 argument to println');
        }
        this.print(value, '\n');
    }

    /**
     * Read a number from the user using `prompt` or the Console's {@link Console#onInput} function, depending
     * on whether the caller was an async method (readLineAsync) or not (readLine)
     * We make sure here to check a few things.
     *
     *    1. If the user checks "Prevent this page from creating additional dialogs," we handle
     *       that gracefully, by checking for a loop, and then returning a DEFAULT value.
     *    2. That we can properly parse a number according to the parse function parseFn passed in
     *       as a parameter. For floats it is just parseFloat, but for ints it is our special parseInt
     *       which actually does not even allow floats, even they they can properly be parsed as ints.
     *    3. The errorMsgType is a string helping us figure out what to print if it is not of the right
     *       type.
     * @param {string} str The prompt string
     * @param {function} parseFn A function to parse the user input to determine if it satisfies
     * the expected datatype. If the return value of parseFn satisfies `!isNaN`, the value is
     * returned. If the result is null, the prompt will repeat until satisfied, or 100 prompts have
     * occurred.
     * @param {string} errorMsgType A string to include in the error message to the user explaining
     * why a given input was rejected. For example, the errorMsgType "an integer," would result in
     * printing "That was not an integer. Please try again." if parseFn failed.
     * @param {boolean} asynchronous A boolean indicating whether this function is being invoked asynchronously.
     * If it is, then {@link readLinePrivateAsync} will be used to read input, which calls {@link Console#onInput}.
     * @returns {number}
     * @private
     */
    readNumber(str, parseFn, errorMsgType, asynchronous) {
        const DEFAULT = Symbol(); // If we get into an infinite recursion, return DEFAULT.
        const MAX_RECURSION_DEPTH = 100;
        // a special indicator that th program should be exiting
        const ABORT = Symbol('ABORT');

        let promptString = str;
        let parsedResult;
        const parseInput = result => {
            if (result === null) {
                return ABORT;
            }
            parsedResult = parseFn(result);
            if (!isNaN(parsedResult)) {
                return parsedResult;
            }
            return null;
        };
        const attemptInput = (promptString, depth, asynchronous) => {
            if (depth >= MAX_RECURSION_DEPTH) {
                return DEFAULT;
            }
            const result = asynchronous
                ? this.readLinePrivateAsync(promptString)
                : this.readLinePrivate(promptString);
            const next = result => {
                return attemptInput(
                    `'${result}' was not ${errorMsgType}. Please try again.\n${str}`,
                    depth + 1,
                    asynchronous
                );
            };
            if (Promise.resolve(result) === result) {
                return result.then(result => {
                    const parsedResult = parseInput(result);
                    if (parsedResult === ABORT) {
                        return null;
                    }
                    if (parsedResult === null) {
                        return next(result);
                    } else {
                        return parsedResult;
                    }
                });
            } else {
                const parsedResult = parseInput(result);
                if (parsedResult === ABORT) {
                    return null;
                }
                if (parsedResult === null) {
                    return next(result);
                } else {
                    return parsedResult;
                }
            }
        };
        const result = attemptInput(promptString, 0, asynchronous);
        if (result === DEFAULT) {
            return 0;
        }
        if (result === null) {
            return null;
        }
        if (!asynchronous) {
            // success
            this.print(str);
            this.println(result);
        }
        return result;
    }

    /**
     * Read a line from the user.
     * @param {str} str - A message associated with the modal asking for input.
     * @returns {str} The result of the readLine prompt.
     * @global
     */
    readLine(str) {
        if (arguments.length !== 1) {
            throw new Error('You should pass exactly 1 argument to readLine');
        }

        const result = this.readLinePrivate(str);
        this.print(str);
        this.println(result);
        return result;
    }

    /**
     * Read a line asynchronously from the user.
     * This will receive input via the Console's configured {@link Console#onInput} function, which by default
     * will return a Promise that resolves with the result of using `window.prompt`, which will
     * block the browser.
     * @param {str} str - A message associated with the modal asking for input.
     * @returns {Promise<string>} The result of the prompt.
     * @global
     */
    async readLineAsync(str) {
        if (arguments.length !== 1) {
            throw new Error('You should pass exactly 1 argument to readLineAsync');
        }

        const result = await this.readLinePrivateAsync(str);
        return result;
    }

    /**
     * Read a bool from the user.
     * @param {str} str - A message associated with the modal asking for input.
     * @returns {str} The result of the readBoolean prompt.
     * @global
     */
    readBoolean(str) {
        if (arguments.length !== 1) {
            throw new Error('You should pass exactly 1 argument to readBoolean');
        }
        return this.readNumber(
            str,
            line => {
                if (line === null) {
                    return NaN;
                }
                line = line.toLowerCase();
                if (line === 'true' || line === 'yes') {
                    return true;
                }
                if (line === 'false' || line === 'no') {
                    return false;
                }
                return NaN;
            },
            'a boolean (true/false)'
        );
    }

    /**
     * Read a bool from the user asynchronously.
     * This will receive input via the Console's configured {@link Console#onInput} function, which by default
     * will return a Promise that resolves with the result of using `window.prompt`, which will
     * block the browser.
     * @param {str} str - A message associated with the modal asking for input.
     * @returns {Promise<boolean>} The result of the onPrompt function if it's a boolean, or 0.
     * @global
     */
    async readBooleanAsync(str) {
        if (arguments.length !== 1) {
            throw new Error('You should pass exactly 1 argument to readBooleanAsync');
        }
        return await this.readNumber(
            str,
            line => {
                if (line === null) {
                    return NaN;
                }
                line = line.toLowerCase();
                if (line === 'true' || line === 'yes') {
                    return true;
                }
                if (line === 'false' || line === 'no') {
                    return false;
                }
                return NaN;
            },
            'a boolean (true/false)',
            true
        );
    }

    /**
     * Read an int with our special parseInt function which doesnt allow floats, even
     * though they are successfully parsed as ints.
     * @param {str} str - A message associated with the modal asking for input.
     * @returns {str} The result of the readInt prompt.
     * @global
     */
    readInt(str) {
        if (arguments.length !== 1) {
            throw new Error('You should pass exactly 1 argument to readInt');
        }

        return this.readNumber(
            str,
            function (x) {
                var resultInt = parseInt(x);
                var resultFloat = parseFloat(x);
                // Make sure the value when parsed as both an int and a float are the same
                if (resultInt === resultFloat) {
                    return resultInt;
                }
                return NaN;
            },
            'an integer'
        );
    }

    /**
     * Read an int from the user asynchronously.
     * This will receive input via the Console's configured {@link Console#onInput} function, which by default
     * will return a Promise that resolves with the result of using `window.prompt`, which will
     * block the browser.
     * @param {str} str - A message associated with the modal asking for input.
     * @returns {Promise<number>} The result of the onPrompt function if it's an int, or 0.
     * @global
     */
    async readIntAsync(str) {
        if (arguments.length !== 1) {
            throw new Error('You should pass exactly 1 argument to readIntAsync');
        }
        return await this.readNumber(
            str,
            function (x) {
                var resultInt = parseInt(x);
                var resultFloat = parseFloat(x);
                // Make sure the value when parsed as both an int and a float are the same
                if (resultInt === resultFloat) {
                    return resultInt;
                }
                return NaN;
            },
            'an integer',
            true
        );
    }

    /**
     * Read a float from the user.
     * @param {str} str - A message associated with the modal asking for input.
     * @returns {str} The result of the readFloat prompt.
     * @global
     */
    readFloat(str) {
        if (arguments.length !== 1) {
            throw new Error('You should pass exactly 1 argument to readFloat');
        }

        return this.readNumber(str, parseFloat, 'a float');
    }

    /**
     * Read a float from the user asynchronously.
     * This will receive input via the Console's configured {@link Console#onInput} function, which by default
     * will return a Promise that resolves with the result of using `window.prompt`, which will
     * block the browser.
     * @param {str} str - A message associated with the modal asking for input.
     * @returns {Promise<number>} The result of the onPrompt function if it's a float, or 0.
     * @global
     */
    async readFloatAsync(str) {
        if (arguments.length !== 1) {
            throw new Error('You should pass exactly 1 argument to readFloatAsync');
        }
        return await this.readNumber(str, parseFloat, 'a float', true);
    }
}

export default Console;