Created
December 2, 2013 05:13
-
-
Save pamelafox/7745401 to your computer and use it in GitHub Desktop.
BabyHint
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* BabyHint does a line-by-line check for common beginner programming mistakes, | |
* such as misspelling, missing spaces, missing commas, etc. It is used in | |
* conjunction with JSHINT to report errors to the user. | |
* | |
* Each error returned contains the members: | |
* { | |
* row : the row at which the error was found | |
* column : the column at which the error was found | |
* text : the error messaage | |
* breaksCode : true if we actually want to prevent them from | |
* doing this | |
* (if false, will only display if JSHINT broke on | |
* the same line) | |
* } | |
*/ | |
window.BabyHint = { | |
EDIT_DISTANCE_THRESHOLD: 2, | |
// We'll get function names from Output.context | |
// non-function keywords go here | |
keywords: [ | |
/* RESERVED WORDS */ | |
"break", | |
"case", | |
"catch", | |
"continue", | |
"default", | |
"do", | |
"else", | |
"finally", | |
"for", | |
"function", | |
"if", | |
"in", | |
"instanceof", | |
"new", | |
"return", | |
"switch", | |
"this", | |
"throw", | |
"try", | |
"typeof", | |
"var", | |
"while", | |
/* JAVASCRIPT OBJECT PROPERTIES AND FUNCTIONS */ | |
/* Omit those included in Output.context */ | |
"charAt", | |
"charCodeAt", | |
"fromCharCode", | |
"indexOf", | |
"lastIndexOf", | |
"length", | |
"pop", | |
"prototype", | |
"push", | |
"replace", | |
"search", | |
"shift", | |
"slice", | |
"substring", | |
"toLowerCase", | |
"toUpperCase", | |
"unshift" | |
], | |
// Expected number of parameters for known functions. | |
// (Some functions can take multiple signatures) | |
// We'll get most of these from Output.context, | |
// so these are just the overrides. | |
functionParamCount: { | |
"acos": 1, | |
"asin": 1, | |
"atan": 1, | |
"atan2": 2, | |
"background": [1, 3], | |
"beginShape": [0, 1], | |
"bezier": 8, | |
"bezierVertex": [6], | |
"box": [1, 2, 3], | |
"color": [3, 4], | |
"colorMode": [1, 2, 4, 5], | |
"cos": 1, | |
"endShape": [0, 1], | |
"dist": 4, | |
"fill": [1, 3, 4], | |
"filter": [1, 2], | |
"get": [2, 3, 4, 5], | |
"image": [3, 5], | |
"line": 4, | |
"loadImage": [1, 3], | |
"max": 2, | |
"min": 2, | |
"PVector": [2, 3], | |
"random": [0, 1, 2], | |
"RegExp": [1, 2], | |
"rect": [4, 5], | |
"set": [3, 4], | |
"sin": 1, | |
"stroke": [1, 3, 4], | |
"tan": 1, | |
"text": [3, 5], | |
"translate": [2, 3], | |
"vertex": [2, 4] | |
}, | |
// A mapping from function name to an example usage of the function | |
// the titles of documentation programs | |
functionFormSuggestion: { | |
// forms that don't have documentation scratchpads | |
// or weird formatting | |
"function" : "var drawWinston = function() { ... };", | |
"while" : "while (x < 20) { ... };" | |
}, | |
// functions in the global context that we want | |
// blacklisted because it's complicated... | |
functionParamBlacklist: [ | |
"debug", | |
"max", | |
"min" | |
], | |
// These properties can be used for malicious purposes | |
// It's just a stop-gap measure, making it much harder | |
bannedProperties: { | |
location: true, | |
document: true, | |
ownerDocument: true, | |
createElement: true | |
}, | |
variables: [], | |
errors: [], | |
inComment: false, | |
spellChecked: false, | |
init: function() { | |
// grab globals from Processing object | |
for (var f in Output.context) { | |
if (typeof Output.context[f] === "function") { | |
BabyHint.keywords.push(f); | |
if (!(f in BabyHint.functionParamCount) && | |
!_.include(BabyHint.functionParamBlacklist, f)) { | |
BabyHint.functionParamCount[f] = Output.context[f].length; | |
} | |
} | |
} | |
}, | |
initDocumentation: function(docs) { | |
for (var i = 0; i < docs.length; i++) { | |
var usage = docs[i].translated_title; | |
// kind of hacky, but many documentation titles don't take the form name(...) ... | |
// so we grab which ones we can. and rely on the intial value of | |
// the functionFormSuggestion map for any others we want to | |
// support rest of them | |
var firstParen = usage.indexOf("("); | |
var name = usage; | |
if (firstParen >= 0) { | |
name = name.substring(0, firstParen).trim(); | |
BabyHint.functionFormSuggestion[name] = usage; | |
} | |
} | |
}, | |
babyErrors: function(source, hintErrors) { | |
var errorLines = {}; | |
var lines = source.split("\n"); | |
BabyHint.errors = []; | |
BabyHint.variables = []; | |
BabyHint.inComment = false; | |
BabyHint.spellChecked = false; | |
if (i == 0) { | |
} | |
// Build a map of the lines on which JSHint produced an error | |
_.each(hintErrors, function(error) { | |
// Get correct index number from the reported line number | |
if (error) { | |
errorLines[error.line - 2] = true; | |
} | |
}); | |
_.each(lines, function(line, index) { | |
// Check the line for errors | |
BabyHint.errors = BabyHint.errors.concat( | |
BabyHint.parseLine(line, index, errorLines[index])); | |
}); | |
return BabyHint.errors; | |
}, | |
// Checks a single line for errors | |
parseLine: function(line, lineNumber, hasError) { | |
var errors = []; | |
if (BabyHint.inComment) { | |
// We are in a multi-line comment. | |
// Look for the end of the comments. | |
line = BabyHint.removeEndOfMultilineComment(line); | |
} | |
if (!BabyHint.inComment) { | |
line = BabyHint.removeComments(line); | |
line = BabyHint.removeStrings(line); | |
// Checks could detect new errors, thus must run on every line | |
errors = errors | |
// check for incorrect function declarations | |
.concat(BabyHint.checkFunctionDecl(line, lineNumber)) | |
// we don't allow ending lines with "=" | |
.concat(BabyHint.checkTrailingEquals(line, lineNumber)) | |
// check for correct number of parameters | |
.concat(BabyHint.checkFunctionParams(line, lineNumber)) | |
// check for banned property names | |
.concat(BabyHint.checkBannedProperties(line, lineNumber)); | |
// Checks only run on lines with existing errors | |
if (hasError) { | |
errors = errors | |
// check for missing space after var | |
.concat(BabyHint.checkSpaceAfterVar(line, lineNumber)); | |
// only check spelling for the first error shown | |
if (!BabyHint.spellChecked) { | |
errors = errors.concat(BabyHint.checkSpelling(line, lineNumber)); | |
BabyHint.spellChecked = true; | |
} | |
} | |
// add new variables for future spellchecking | |
BabyHint.variables = BabyHint.variables.concat( | |
BabyHint.getVariables(line)); | |
} | |
return errors; | |
}, | |
removeComments: function(line) { | |
// replace commented out code with whitespaces | |
// first check for "//" | |
var index = line.indexOf("//"); | |
if (index !== -1) { | |
line = line.slice(0, index); | |
} | |
// now check for "/*" | |
while (line.indexOf("/*") !== -1) { | |
index = line.indexOf("/*"); | |
var closeIndex = line.indexOf("*/"); | |
while (closeIndex !== -1 && closeIndex <= index + 1) { | |
// unopened comments - let JSHINT catch these | |
// we'll just remove the extra */ for now | |
line = line.slice(0, closeIndex) + | |
" " + line.slice(closeIndex + 2); | |
closeIndex = line.indexOf("*/"); | |
} | |
if (closeIndex > index + 1) { | |
// found /* */ on the same line | |
var comment = line.slice(index, closeIndex + 2); | |
line = line.slice(0, index) + | |
comment.replace(/./g, " ") + | |
line.slice(closeIndex + 2); | |
} else if (closeIndex === -1) { | |
// beginning of a multi-line comment | |
// inComment won't take effect until the next line | |
BabyHint.inComment = true; | |
line = line.slice(0, index); | |
} | |
} | |
return line; | |
}, | |
removeEndOfMultilineComment: function(line) { | |
var index = line.indexOf("*/"); | |
if (index !== -1) { | |
BabyHint.inComment = false; | |
line = line.slice(0, index + 2).replace(/./g, " ") + | |
line.slice(index + 2); | |
} | |
return line; | |
}, | |
removeStrings: function(line) { | |
// currently JSHINT doesn't allow multi-line strings, so | |
// all string quotes should be closed on the same line | |
var openIndex = -1; | |
var quoteType = null; | |
for (var i = 0; i < line.length; i++) { | |
var letter = line[i]; | |
if (openIndex === -1) { | |
// look for any type of quotes | |
if (letter === "\"") { | |
openIndex = i; | |
quoteType = "\""; | |
} else if (letter === "\'") { | |
openIndex = i; | |
quoteType = "\'"; | |
} | |
} else if (letter === quoteType) { | |
// replace string contents with whitespace | |
var string = line.slice(openIndex + 1, i); | |
line = line.slice(0, openIndex + 1) + | |
string.replace(/./g, " ") + | |
line.slice(i); | |
openIndex = -1; | |
} | |
} | |
return line; | |
}, | |
checkFunctionDecl: function(line, lineNumber) { | |
var errors = []; | |
var functions = line.match(/function\s+\w+/g); | |
_.each(functions, function(fun) { | |
var name = fun.split(/\s+/g)[1]; | |
// I18N: Don't translate the '\" var %(name)s = function() {}; \"' part | |
var error = { | |
row: lineNumber, | |
column: line.indexOf(fun), | |
text: $._("If you want to define a function, you should use \"var %(name)s = function() {}; \" instead!", {name: name}), | |
breaksCode: true, | |
source: "funcdeclaration" | |
}; | |
errors.push(error); | |
}); | |
return errors; | |
}, | |
checkBannedProperties: function(line, lineNumber) { | |
var errors = []; | |
var words = line.split(/[^~`@#\$\^\w]/g); | |
_.each(words, function(word) { | |
if (BabyHint.bannedProperties.hasOwnProperty(word)) { | |
var error = { | |
row: lineNumber, | |
column: line.indexOf(word), | |
text: $._("%(word)s is a reserved word.", {word: word}), | |
breaksCode: true, | |
source: "bannedwords" | |
}; | |
errors.push(error); | |
} | |
}); | |
return errors; | |
}, | |
checkSpelling: function(line, lineNumber) { | |
var errors = []; | |
var words = line.split(/[^~`@#\$\^\w]/g); | |
var skipNext = false; | |
_.each(words, function(word) { | |
if (word.length > 0 && !skipNext) { | |
var editDist = BabyHint.editDistance(word); | |
var dist = editDist.editDistance; | |
var keyword = editDist.keyword; | |
if (dist > 0 && dist <= BabyHint.EDIT_DISTANCE_THRESHOLD && | |
dist < keyword.length - 1) { | |
var error = { | |
row: lineNumber, | |
column: line.indexOf(word), | |
text: $._("Did you mean to type \"%(keyword)s\" instead of \"%(word)s\"?", {keyword: keyword, word: word}), | |
breaksCode: false, | |
source: "spellcheck" | |
}; | |
// if we have usage forms, display them as well. | |
if (BabyHint.functionFormSuggestion[keyword]) { | |
error.text += " " + $._("In case you forgot, you can use it like \"%(usage)s\"", {usage: BabyHint.functionFormSuggestion[keyword]}); | |
} | |
errors.push(error); | |
} | |
} | |
skipNext = (word === "var"); | |
}); | |
return errors; | |
}, | |
editDistance: function(word) { | |
var wordOrig = word; | |
word = word.toLowerCase(); | |
// Dynamic programming implementation of Levenshtein Distance. | |
// The rows are the letters of the keyword. | |
// The cols are the letters of the word. | |
var make2DArray = function(rows, cols, initialVal) { | |
initialVal = (typeof initialVal === "undefined") ? 0 : initialVal; | |
var array2D = []; | |
for (var i = 0; i < rows; i++) { | |
array2D[i] = []; | |
for (var j = 0; j < cols; j++) { | |
array2D[i][j] = initialVal; | |
} | |
} | |
return array2D; | |
}; | |
var minDist = Infinity; | |
var minWord = ""; | |
_.each(BabyHint.keywords.concat(BabyHint.variables), function(keyword) { | |
var keywordOrig = keyword; | |
keyword = keyword.toLowerCase(); | |
// Take care of simple case of capitalization as only difference | |
if (keyword === word && keywordOrig !== wordOrig) { | |
minDist = 1; | |
minWord = keywordOrig; | |
return; | |
} | |
// skip words with lengths that are too different | |
if (Math.abs(keyword.length - word.length) > BabyHint.EDIT_DISTANCE_THRESHOLD) { | |
return; | |
} | |
var rows = keyword.length; | |
var cols = word.length; | |
var table = make2DArray(rows, cols, 1); | |
// initialize first cell | |
if (keyword[0] === word[0]) { | |
table[0][0] = 0; | |
} | |
// initialize first row | |
for (var c = 1; c < cols; c++) { | |
var diff = keyword[0] === word[c] ? 0 : 1; | |
table[0][c] = table[0][c - 1] + diff; | |
} | |
// initialize first col | |
for (var r = 1; r < rows; r++) { | |
var diff = keyword[r] === word[0] ? 0 : 1; | |
table[r][0] = table[r - 1][0] + diff; | |
} | |
// compute table | |
for (var i = 1; i < rows; i++) { | |
var minInRow = Number.MAX_VALUE; | |
for (var j = 1; j < cols; j++) { | |
var diff = keyword[i] === word[j] ? 0 : 1; | |
var dist = _.min([table[i - 1][j] + 1, | |
table[i][j - 1] + 1, | |
table[i - 1][j - 1] + diff]); | |
minInRow = Math.min(minInRow, dist); | |
table[i][j] = dist; | |
} | |
// break out if entire row exceeds threshold | |
if (minInRow > BabyHint.EDIT_DISTANCE_THRESHOLD) { | |
return; | |
} | |
} | |
if (table[rows - 1][cols - 1] < minDist) { | |
minDist = table[rows - 1][cols - 1]; | |
minWord = keywordOrig; | |
} | |
}); | |
return {editDistance: minDist, keyword: minWord}; | |
}, | |
checkSpaceAfterVar: function(line, lineNumber) { | |
// Sometimes students forget the space between "var" and the | |
// variable name. This only finds the first occurence of this | |
// missing space | |
var errors = []; | |
var regex = /var\w+/g; | |
var matches = line.match(regex); | |
if (matches) { | |
var variableName = matches[0].slice(3); | |
var error = { | |
row: lineNumber, | |
column: line.search(regex) + 3, | |
text: $._("Did you forget a space between \"var\" and \"%(variable)s\"?", {variable: variableName}), | |
breaksCode: false | |
}; | |
errors.push(error); | |
} | |
return errors; | |
}, | |
checkTrailingEquals: function(line, lineNumber) { | |
var errors = []; | |
var i = line.length - 1; | |
// find the last character in the line | |
while (line[i] === " ") { | |
i--; | |
} | |
if (line[i] === "=") { | |
var error = { | |
row: lineNumber, | |
column: i, | |
text: $._("You can't end a line with \"=\""), | |
breaksCode: true | |
}; | |
errors.push(error); | |
} | |
return errors; | |
}, | |
getVariables: function(line) { | |
// Add any new variables for future spellchecking. | |
// This misses variables that are not the first variable to be | |
// declared on a line (e.g. var draw = function() {var x = 3;};) | |
var variables = []; | |
var regex = /\s*var\s*([A-z]\w*)\s*(;|=)/; | |
if (regex.exec(line)) { | |
var variable = regex.exec(line)[1]; | |
variables.push(variable); | |
} | |
// Add any variables declared as function parameters | |
// Also only checks first function declaration on the line | |
var functionRegex = /function\s*\(([\w\s,]*)\)/; | |
if (functionRegex.exec(line)) { | |
var fun = RegExp.$1; | |
var params = fun.split(/\s*,\s*/); | |
_.each(params, function(param) { | |
if (param) { | |
variables.push(param); | |
} | |
}); | |
} | |
return variables; | |
}, | |
checkFunctionParams: function(line, lineNumber) { | |
// Many Processing functions don't break if passed the wrong | |
// number of parameters, but they also don't work. | |
// We want to display errors for these specific cases. | |
var errors = []; | |
// first match up pairs of parentheses | |
var parenPairs = {}; | |
var stack = []; | |
for (var i = 0; i < line.length; i++) { | |
if (line[i] === "(") { | |
stack.push(i); | |
} else if (line[i] === ")") { | |
if (stack.length === 0) { | |
var error = { | |
row: lineNumber, | |
column: i, | |
text: $._("It looks like you have an extra \")\""), | |
breaksCode: false, | |
source: "paramschecker" | |
}; | |
errors.push(error); | |
// if we messed up the parens matching, | |
// parameter counts will be off | |
return errors; | |
} else { | |
parenPairs[stack.pop()] = i; | |
} | |
} | |
} | |
if (stack.length > 0) { | |
// check if there's anything left in the stack | |
var error = { | |
row: lineNumber, | |
column: stack.pop(), | |
text: $._("It looks like you are missing a \")\" - does every \"(\" have a corresponding closing \")\"?"), | |
breaksCode: false, | |
source: "paramschecker" | |
}; | |
errors.push(error); | |
// if we messed up the parens matching, | |
// parameter counts will be off | |
return errors; | |
} | |
// find all function calls | |
var functions = line.match(/\w+\s*\(/g) || []; | |
// find all functions calls on an object | |
var objectFunctions = line.match(/\.\s*\w+\s*\(/g) || []; | |
objectFunctions = _.map(objectFunctions, function(fun) { | |
// remove the leading '.' | |
var functionStart = fun.indexOf(fun.match(/\w/g)[0]); | |
return fun.slice(functionStart); | |
}); | |
// go through functions from right to left | |
for (var i = functions.length - 1; i >= 0; i--) { | |
var index = line.lastIndexOf(functions[i]); | |
var functionName = functions[i].split(/\(\s*/g)[0]; | |
// extract the stuff inside the parens | |
index += functionName.length; | |
var params = line.slice(index, parenPairs[index] + 1); | |
// check for missing commas | |
var spacesBetween = params.match(/[A-z0-9]+\s+[A-z0-9]+/g); | |
if (spacesBetween) { | |
var col = line.indexOf(spacesBetween[0]); | |
while (line[col] !== " ") { | |
col++; | |
} | |
var error = { | |
row: lineNumber, | |
column: col, | |
text: $._("Did you forget to add a comma between two parameters?"), | |
breaksCode: false, // JSHINT should break on these lines, | |
source: "paramschecker" | |
}; | |
errors.push(error); | |
// this might confuse the parameter count, so move on for now | |
break; | |
} | |
// count the parameters passed | |
var numParams; | |
var numCommas = params.match(/,/g); | |
if (numCommas) { | |
numParams = numCommas.length + 1; | |
} else { | |
numParams = params.match(/[^\s()]/g) ? 1 : 0; | |
} | |
// ignore functions of objects | |
if (!_.include(objectFunctions, functions[i])) { | |
// check if parameters passed matches the expected number | |
functionName = functionName.replace(/\s/g, ""); | |
var expectedParams = BabyHint.functionParamCount[functionName]; | |
var text; | |
var functionCall; | |
if (typeof expectedParams !== "undefined") { | |
functionCall = "\"" + functionName + "()\""; | |
if (typeof expectedParams === "number" && | |
numParams !== expectedParams) { | |
text = $.ngettext("%(name)s takes 1 parameter, not %(given)s!", "%(name)s takes %(num)s parameters, not %(given)s!", expectedParams, {name: functionCall, given: numParams}); | |
} else if (typeof expectedParams !== "number" && | |
!_.include(expectedParams, numParams)) { | |
var listOfParams = "" + expectedParams[0]; | |
for (var j = 1; j < expectedParams.length - 1; j++) { | |
listOfParams += ", " + expectedParams[j]; | |
} | |
listOfParams += " " + $._("or") + " " + | |
expectedParams[expectedParams.length - 1]; | |
text = $._("%(name)s takes %(list)s parameters, not %(given)s!", {name: functionCall, list: listOfParams, given: numParams}); | |
} | |
} | |
if (text) { | |
var functionForm = BabyHint.functionFormSuggestion[functionName]; | |
if (functionForm) { | |
text = $._("It looks like you're trying to use %(name)s. In case you forgot, you can use it like: %(usage)s", {name: functionCall, usage: "\"" + functionForm + "\""}); | |
} | |
} | |
if (text) { | |
var error = { | |
row: lineNumber, | |
column: index, | |
text: text, | |
breaksCode: true, | |
source: "paramschecker" | |
}; | |
errors.push(error); | |
} | |
} | |
// remove this function call so we don't mess up future comma counts | |
line = line.slice(0, index) + params.replace(/./g, "0") + | |
line.slice(parenPairs[index] + 1); | |
} | |
return errors; | |
} | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
people hate the oh noes guy, they have no idea how hard he trie;s not to be annoying, he is only trying to help their code not have syntax or logic errors so it can work as the intended, poor little man