console/console.js

/* Console
 * =====================
 * A console represents a text console that allows the user to print, println,
 * and read an integer using readInt, and read a float using readFloat. These
 * functions pop up prompt dialogs and make sure that the results are actually
 * of the desired type.
 *
 * @author  Jeremy Keeshin    July 9, 2012
 *
 */

'use strict';

var safeEval = require('codehs-js-utils').safeEval;
var testInfiniteLoops = require('codehs-js-utils').testInfiniteLoops;

var lines = [];
var solution = null;
var TESTER_MESSAGE = '#tester-message';
var PUBLIC_METHODS = [];

/**
 * Set up an instance of the console library.
 * @constructor
 */
function CodeHSConsole() {
    this.internalOutput = [];
    this.internalOutputBuffer = '';
}

/**
 * Adds a method to the public methods array.
 * @param {string} name - Name of the method.
 */
CodeHSConsole.registerPublicMethod = function(name) {
    PUBLIC_METHODS.push(name);
};

/**
 * Generate strings for the public methods to bring them to the
 * public namespace without having to call them with the console instance.
 * @returns {string} Line broken function definitions.
 */
CodeHSConsole.getNamespaceModifcationString = function() {
    var result = '';
    for (var i = 0; i < PUBLIC_METHODS.length; i++) {
        var curMethod = PUBLIC_METHODS[i];

        // Actually create a method in this scope with the name of the
        // method so the student can easily access it. For example, we
        // might have a method like CodeHSConsole.prototype.print, but we
        // want the student to be able to access it with just `print`, but
        // the proper context for this.
        result +=
            'function ' +
            curMethod +
            '(){\n' +
            '\treturn __console__.' +
            curMethod +
            '.apply(__console__, arguments);\n' +
            '}\n';
    }
    return result;
};

/**
 * Generate stub strings for the public methods to bring them to the
 * namespace without having to call them with the console instance.
 * @returns {string} Line broken function definitions.
 */
CodeHSConsole.getStubString = function() {
    var result = '';
    _.each(PUBLIC_METHODS, function(method) {
        result += 'function ' + method + '(){\n' + '\treturn 0;\n' + '}\n';
    });
    return result;
};

/**
 * Set the solution code for a given exercise.
 * @param {string} soln - Solution code.
 */
CodeHSConsole.setSolution = function(soln) {
    solution = soln;
};

/**
 * Check the console output for correctness against solution code.
 * returns {object} Dictionary containing boolean of success and message.
 */
CodeHSConsole.prototype.checkOutput = function() {
    if (!solution) {
        return;
    }
    var graded = {
        success: true,
        message: '<strong>Nice job!</strong> You got it!',
    };

    if ($('#console').html().length === 0) {
        graded.success = false;
        graded.message = "You didn't print anything.";
    } else if (lines.length != solution.length) {
        graded.success = false;
        graded.message =
            '<strong>Not quite.</strong> Take a look at the ' +
            'example output in the exercise tab.';
    } else {
        for (var i = 0; i < lines.length; i++) {
            var line = lines[i];
            var correct = solution[i];
            var regex = new RegExp(correct);
            if (line.search(regex) !== 0) {
                graded.success = false;
                graded.message =
                    '<strong>Not quite.</strong> Take a look ' +
                    'at the example output in the exercise tab.';
            }
        }
    }

    $(TESTER_MESSAGE).html(graded.message);
    if (graded.success) {
        $(TESTER_MESSAGE)
            .removeClass('gone')
            .removeClass('alert-error')
            .addClass('alert-info');
    } else {
        $(TESTER_MESSAGE)
            .removeClass('gone')
            .removeClass('alert-info')
            .addClass('alert-error');
    }

    return graded;
};

var bufferedOutputToArray = function(bufferedOutput) {
    var bufferedOutputArray = bufferedOutput.split('\n');
    // remove the trailing "" that happens if the final element is a \n
    if (bufferedOutputArray[bufferedOutputArray.length - 1].length === 0) {
        bufferedOutputArray = bufferedOutputArray.slice(0, -1);
    }
    return bufferedOutputArray;
};

/**
 * A non-dom-mutating print for use in autograders.
 */
CodeHSConsole.prototype.quietPrint = function(string) {
    if (!this.internalOutputBuffer) {
        this.internalOutputBuffer = '';
    }
    this.internalOutputBuffer += string;
};

/**
 * A non-dom-mutating println for use in autograders.
 */
CodeHSConsole.prototype.quietPrintln = function(anything) {
    this.quietPrint(anything + '\n');
};

/**
 * Gets the internal output.
 */
CodeHSConsole.prototype.flushQuietOutput = function() {
    if (!this.internalOutputBuffer) {
        this.internalOutputBuffer = '';
    }
    if (!this.internalOutput) {
        this.internalOutput = [];
    }
    var output = this.internalOutput.concat(bufferedOutputToArray(this.internalOutputBuffer));
    this.internalOutput = [];
    this.internalOutputBuffer = '';
    return output;
};

/**
 * Get the output from the console.
 * @returns {string}
 */
CodeHSConsole.getOutput = function() {
    return $('#console').text();
};

/**
 * Check if the console exists.
 * Important to check before attempting to select and extract output.
 */
CodeHSConsole.exists = function() {
    return $('#console').exists();
};

/**
 * Clear the console's text.
 */
CodeHSConsole.clear = function() {
    lines = [];
    $('#console').html('');
    $(TESTER_MESSAGE).addClass('gone');
};

/**
 * Private method used to read a line.
 * @param {string} str - The line to be read.
 * @param {boolean} looping - Unsure. This is a messy method.
 */
CodeHSConsole.prototype.readLinePrivate = function(str, looping) {
    if (typeof looping === 'undefined' || !looping) {
        this.print(str);
    }
    var console = $('#console');
    if (console.length) {
        $('#console').css('margin-top', '180px');
        // take max 20 lines, last line is prompt string so we remove and
        // add extra spacing before putting it back on
        var lines = _.takeRight(
            $('#console')
                .text()
                .split('\n'),
            21
        );

        lines.pop();
        var text = lines.concat(['', '', str]).join('\n');
        var result = prompt(text);

        $('#console').css('margin-top', '0px');
    } else {
        var lines = this.internalOutput.slice(-10);
        var result = prompt(lines.join('\n'));
    }
    if (typeof looping === 'undefined' || !looping) {
        this.println(result);
    }
    return result;
};

/**
 * This is how you run the code, but get access to the
 * state of the console library. The current instance
 * becomes accessible in the code.
 * @param {string} code - The code from the editor.
 */
CodeHSConsole.prototype.runCode = function(code, options) {
    options = options || {};
    var lineOffset = options.lineOffset || 0;
    var publicMethodStrings = CodeHSConsole.getNamespaceModifcationString();
    var stubString = CodeHSConsole.getStubString();

    // This code will create a local (to the student's program) `console`
    // variable, so console.log will be an alias to `println` so student code
    // can act more like "real" javascript
    var consoleOverride = ';var console = {}; console.log = println;\n';

    // To prevent issues with the native `Set`, we swap it out here.
    var setOverride = ';var __nativeSet=window.Set;var Set=window.chsSet;';
    var setRestore = ';window.Set=__nativeSet;';

    var wrap = '';
    wrap += publicMethodStrings;
    wrap += consoleOverride;
    wrap += setOverride;

    if (!options.overrideInfiniteLoops) {
        // tool all while loops
        var whileLoopRegEx = /while\s*\((.*)\)\s*{/gm;
        var forLoopRegEx = /for\s*\((.*)\)\s*{/gm;
        var doWhileRegEx = /do\s*\{/gm;

        // Inject into while loops
        code = code.replace(whileLoopRegEx, function(match, p1, offset, string) {
            var lineNumber = string.slice(0, offset).split('\n').length - lineOffset;
            var c =
                "if(___nloops++>15000){var e = new Error('Your while loop on line " +
                lineNumber +
                " may contain an infinite loop. Exiting.'); e.name = 'InfiniteLoop'; e.lineNumber = " +
                lineNumber +
                '; throw e;}';
            return 'var ___nloops=0;while(' + p1 + ') {' + c;
        });
        // Inject into while loops
        // See comment above for while loops.
        code = code.replace(forLoopRegEx, function(match, p1, offset, string) {
            var lineNumber = string.slice(0, offset).split('\n').length - lineOffset;
            var c =
                "if(___nloops++>15000){var e = new Error('Your for loop on line " +
                lineNumber +
                " may contain an infinite loop. Exiting.'); e.name = 'InfiniteLoop'; e.lineNumber = " +
                lineNumber +
                '; throw e;}';
            return 'var ___nloops=0;for(' + p1 + '){' + c;
        });
        // Inject into do-while loops
        code = code.replace(doWhileRegEx, function(match, offset, string) {
            var lineNumber = string.slice(0, offset).split('\n').length - lineOffset;
            var c =
                "if(___nloops++>15000){var e = new Error('Your do-while loop on line " +
                lineNumber +
                " may contain an infinite loop. Exiting.'); e.name = 'InfiniteLoop'; e.lineNumber = " +
                lineNumber +
                '; throw e;}';
            return 'var ___nloops=0;do {' + c;
        });
    }

    wrap += code;
    wrap += "\n\nif(typeof start == 'function') {start();} ";
    wrap += setRestore;
    wrap += '__console__.checkOutput();';

    this.internalOutput = [];
    return safeEval(wrap, this, '__console__');
};

/**
 * Method to test whether the code is requesting user input at all.
 * @param {string} code - The code from the editor
 */
CodeHSConsole.prototype.hasUserinput = function(code) {
    return code.match(new RegExp('readLine|readInt|readFloat|readBoolean|readNumber'));
};

/** ************* PUBLIC METHODS *******************/

/**
 * Clear the console.
 */
CodeHSConsole.prototype.clear = function() {
    if (arguments.length !== 0) {
        throw new Error('You should not pass any arguments to clear');
    }
    CodeHSConsole.clear();
};
CodeHSConsole.registerPublicMethod('clear');

/**
 * Print a line to the console.
 * @param {string} ln - The string to print.
 */
CodeHSConsole.prototype.print = function(ln) {
    if (arguments.length !== 1) {
        throw new Error('You should pass exactly 1 argument to print');
    }

    var console = $('#console');
    if (console.length) {
        console.html($('#console').html() + ln);
        console.scrollTop($('#console')[0].scrollHeight);
        lines = console.html().split('\n');
        lines.splice(lines.length - 1, 1);
    } else {
        // we must be running outside of the site.
        this.internalOutput.push(ln);
        window.console.log(ln);
    }
};
CodeHSConsole.registerPublicMethod('print');

/**
 * Print a line to the console.
 * @param {string} ln - The string to print.
 */
CodeHSConsole.prototype.println = function(ln) {
    if (arguments.length === 0) {
        ln = '';
    } else if (arguments.length !== 1) {
        throw new Error('You should pass exactly 1 argument to println');
    } else {
        this.print(ln + '\n');
        var scrollTop = $('#console').scrollTop();
    }
};
CodeHSConsole.registerPublicMethod('println');

/* Read a number from the user using JavaScripts prompt function.
 * 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.
 */
CodeHSConsole.prototype.readNumber = function(str, parseFn, errorMsgType) {
    var DEFAULT = 0; // If we get into an infinite loop, return DEFAULT.
    var INFINITE_LOOP_CHECK = 100;

    var prompt = str;
    var looping = false;
    var loopCount = 0;
    while (true) {
        var result = this.readLinePrivate(prompt, looping);
        if (result === null) {
            return null;
        }
        result = parseFn(result);

        // Then it was okay.
        if (!isNaN(result)) {
            return result;
        }

        if (result === null) {
            return DEFAULT;
        }
        if (loopCount > INFINITE_LOOP_CHECK) {
            return DEFAULT;
        }
        prompt = 'That was not ' + errorMsgType + '. Please try again. ' + str;
        looping = true;
        loopCount++;
    }
};
CodeHSConsole.registerPublicMethod('readNumber');

/**
 * 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.
 */
CodeHSConsole.prototype.readLine = function(str) {
    if (arguments.length !== 1) {
        throw new Error('You should pass exactly 1 argument to readLine');
    }

    return this.readLinePrivate(str, false);
};
CodeHSConsole.registerPublicMethod('readLine');

/**
 * 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.
 */
CodeHSConsole.prototype.readBoolean = function(str) {
    if (arguments.length !== 1) {
        throw new Error('You should pass exactly 1 argument to readBoolean');
    }
    return this.readNumber(
        str,
        function(x) {
            if (x === null) {
                return NaN;
            }
            x = x.toLowerCase();
            if (x == 'true' || x == 'yes') {
                return true;
            }
            if (x == 'false' || x == 'no') {
                return false;
            }
            return NaN;
        },
        'a boolean (true/false)'
    );
};
CodeHSConsole.registerPublicMethod('readBoolean');

/* 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.
 */
CodeHSConsole.prototype.readInt = function(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'
    );
};
CodeHSConsole.registerPublicMethod('readInt');

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

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

module.exports = {
    CodeHSConsole: CodeHSConsole,
    PUBLIC_METHODS: PUBLIC_METHODS,
};