diff --git a/src/main/js/lib/scriptcraft.js b/src/main/js/lib/scriptcraft.js index cc386da4a..eef58d7ab 100644 --- a/src/main/js/lib/scriptcraft.js +++ b/src/main/js/lib/scriptcraft.js @@ -584,7 +584,12 @@ function __onEnable ( __engine, __plugin, __script ) { // function _eval(expr) { var result; - + // Let's try to find a function name, to help with debugging + // We'll find a comment #f:fnName in our call ; so we need to search now (coffeescript evaluation will remove the comment) + var fnName = ''; + if(expr.indexOf('#f:') != -1) { + fnName = expr.substring(expr.indexOf('#f:') + '#f:'.length); + } try { expr = require('coffee-script').CoffeeScript.compile(expr, { bare: true }); console.log('CoffeeScript compilation result:' + expr); @@ -594,7 +599,7 @@ function __onEnable ( __engine, __plugin, __script ) { if ( nashorn ) { // On Nashorn, we can use the native `load` function, which preserves return types better // (`undefined` is not cast to `null`) - result = load( { script: expr, name: '' } ); + result = load( { script: expr, name: fnName } ); } else { result = __engine.eval( expr ); @@ -660,7 +665,8 @@ function __onEnable ( __engine, __plugin, __script ) { } } catch ( e ) { logError( 'Error while trying to evaluate javascript: ' + fnBody + ', Error: '+ e ); - echo( sender, 'Error while trying to evaluate javascript: ' + fnBody + ', Error: '+ e ); + require('error-handler').handleException(e, sender); + //echo( sender, 'Error while trying to evaluate javascript: ' + fnBody + ', Error: '+ e ); } finally { /* wph 20140312 don't delete self on nashorn until https://bugs.openjdk.java.net/browse/JDK-8034055 is fixed diff --git a/src/main/js/modules/clickable-chat/index.js b/src/main/js/modules/clickable-chat/index.js new file mode 100644 index 000000000..7fcbb518a --- /dev/null +++ b/src/main/js/modules/clickable-chat/index.js @@ -0,0 +1,121 @@ +/* Send to the player special chat objects (containing links) + * Taken from gnanclass/index.js (need to find a way to have it in one place and properly require + * it) + * */ +function sendChat(player, chatObjects) { + var json = JSON.stringify(chatObjects); + if (__plugin.canary) { + var Canary = Packages.net.canarymod.Canary; + var ccFactory = Canary.factory().getChatComponentFactory(); + var cc = ccFactory.deserialize(json); + player["message(ChatComponent[])"](cc); + } else { + var ComponentSerializer = Packages.net.md_5.bungee.chat.ComponentSerializer; + var cc = ComponentSerializer.parse(json); + player.spigot().sendMessage(cc); + } +} + + +/* Return a list of callpath for function contained inside obj + * For example, if module contains 'foo', 'bar', return ['foo', 'bar'] + * if recursive if true, this will also parse the module submodule (objects) + * e.g {foo: {baz : function(){}, name: 'foo.bar'}, bar: function(){}} will return + * ['bar', 'foo.baz'] + * */ +var findFunctions = function(module, recursive) { + if (typeof recursive === "undefined") + recursive = false; + var out = []; + for (var property in module) { + if (!module.hasOwnProperty(property)) + continue; + var callPath = ""; + if (typeof module[property] === "function") { + callPath = property; + out.push(callPath); + } + if (recursive && typeof module[property] === "object") { + var child = findFunctions(module[property], recursive); + if (child.length === 0) + continue; + // Don't forget to prepend child functions with the object's name + for (var i = 0; i < child.length; i++) { + callPath = property + "." + child[i]; + out.push(callPath); + } + } + } + return out; +}; + +/* Return a chat event, with a link named 'displayName' which will execute callPath on click (as a + * js function, so prepend with /js, append with ()). args is optional ; if supplied, it should be + * an array of args which will be added to the callStr + */ + +var executableLink = function(displayName, color, callPath, args) { + var callStr = ""; + if (typeof args !== undefined && args.length > 0) { + // We can't use directly args.join() because : + // For String, since we are generating a JS-valid line, we need to add quotes around the + // String value And also, we need to make sure we don't have quotes inside the value + var fixedArgs = []; + args.forEach(function(a) { + if (typeof a === "string") { + fixedArgs.push("\"" + a.replace("\"", "\'") + "\""); + } else { + fixedArgs.push(String(a)); + } + }); + var argCall = fixedArgs.join(", "); + callStr = "/js " + callPath + "(" + argCall + ")"; + } else { + callStr = "/js " + callPath + "()"; + } + callStr += "#f:" + displayName; + var out = { + text: displayName, + clickEvent: {action: "run_command", value: callStr}, + color: color + }; + return out; +}; + +var displayPlayerFunctions = function(player) { + if (!player) { + console.warn("displayPlayerFunctions called without a player, should not happen"); + return; + } + var funcs = findFunctions(global[player.name], true); + var chat = []; + for (var i = 0; i < funcs.length; i++) { + var fName = funcs[i]; + var displayName = fName; + var args = []; + var callPath = player.name + "." + fName; + var color = i % 2 === 0 && "blue" || "light_purple"; + var link = executableLink(displayName, color, callPath, args); + chat.push(link); + // nice formatting : two links per line, with padding between them to simulate a table + if (i !== 0 && (i % 2 == 1)) { + chat.push({text: "\n"}); + } else { + var padLength = 40 - displayName.length; + var padding = ""; + for (var j = 0; j < padLength; j++) { + padding += " "; + } + chat.push({text: padding}); + } + } + sendChat(player, chat); +}; + + +module.exports = { + findFunctions: findFunctions, + executableLink: executableLink, + displayPlayerFunctions: displayPlayerFunctions, + sendChat: sendChat, +}; diff --git a/src/main/js/modules/error-handler/index.js b/src/main/js/modules/error-handler/index.js new file mode 100644 index 000000000..800068c73 --- /dev/null +++ b/src/main/js/modules/error-handler/index.js @@ -0,0 +1,243 @@ +var File = java.io.File; +var utils = require("utils"); +var entities = require("entities"); // Used to check if something is a player + +// Return the players who made the code which generated the exception +// by looking in the stack trace. +// Return an array of all the involved player (usually, only one person) +// return an empty array if we couldn't find one, or if they are offline +var findProgrammerPlayers = function(exception) { + var out = []; + var frames = []; + if (nashorn) { + frames = utils.array(exception.getStackTrace()); + } else { + console.log("findProgrammerPlayers needs nashorn engine"); + } + + frames.forEach(function(frame) { + var file = new File(frame.fileName); + if (!file.isFile()) + return; + // In a classroom environment, a player 'Bob' scripts are inside a folder 'Bob/'. We can't + // look directly at the parentFile name, because the scripts can be organized as you want + // inside 'Bob/' : they can be in subfolders if you want So we'll just look every parentFile + // name until root (no more parentFile), and test if it's a connected player :) + while ((file = file.parentFile) !== null) { + // if utils.player is called with an empty string, it will return self (spigot's + // Server.getPlayer() quirk) - obviously we don't want to use it + if (file.name === "") { + continue; + } + var player = utils.player(file.name); + if (entities.player(player)) { + out.push(player); + return; + } + } + }); + return out; +}; + +// Find player to send the exception to. +// fastMode is an optional parameter : if it's true, we will simply look in the exception stack, and +// not in the event parameters +var findReceivers = function(event, exception, fastMode) { + // First, let's see if we can find who made an error in his code ... + var out = []; + out = findProgrammerPlayers(exception); + if (out.length > 0) { + return out; + } + if (fastMode) { + return []; + } + // Now, let's try to see if this event involves a player + // PlayerEvent : they all have a player parameter, it's easy + if (entities.player(event.player)) { + return [event.player]; + } + // EntityEvent : some can apply to a player ( like EntityDamageEvent), so let's try for this + if (entities.player(event.entity)) { + return [event.entity]; + } + // InventoryEvent : a player should be involved ; we must look in the view + if (event.view && entities.player(event.view.player)) { + return [event.view.player]; + } + // InventoryMoveItemEvent : a player can be involved (but it can be autonomous, with hopper for + // example) + if (event.sourceInventory && entities.player(event.sourceInventory.holder)) { + return [event.sourceInventory.holder]; + } + if (event.destinationInventory && entities.player(event.destinationInventory.holder)) { + return [event.destinationInventory.holder]; + } + + // PlayerLeashEntityEvent : a player will be involved, but he'll be named leasher + if (entities.player(event.leasher)) { + return [event.leasher]; + } + // TabCompleteEvent : a player can be involved, as the sender + if (entities.player(event.sender)) { + return [event.sender]; + } + + // VehicleEvent : a player can be involved, either as a vehicle, or a passenger + if (entities.player(event.vehicle)) { + return [event.vehicle]; + } + if (event.vehicle && entities.player(event.vehicle.passenger)) { + return [event.vehicle.passenger]; + } + // There is still some event that are uncovered here (for example, if a ProjectileLaunchEvent, a + // player might be involved as the shooter of the new projectile). However, it's much too + // tedious to check for every possible event. Let's admit we're out of luck + return []; +}; + +// Return true if the frame contains function name : it needs to be a direct call by command +// (identified by methodName beeing ) and the function name need to have been identified. +// See /lib/scriptcraft.js:_eval to see how the function name is identified + +var containsFunctionName = function(frame) { + return (frame.methodName == "" || frame.methodName == ":program") && + frame.fileName != ""; +}; + +// Take a single frame, and return a pretty string +var prettifyFrame = function(frame) { + var out = ""; + // Note : in a nashorn frame, methodName is more akin to a "location" : it can be either + // (function defined inside a .js file), (function called with /js), + // and __FuncName$ (scriptcraft's internal, glue to nashorn) + // If the exception is a java exception, in the javascript part of the exception, the syntax + // is slightly different. In particular, is :program + + if (containsFunctionName(frame)) { + return "Function name : ".white() + frame.fileName.purple(); + } + // We don't want to display the full path (too long, confusing), or only the filename (too + // short, can't know if the issue is in your code or another So we'll go back 2 levels to + // provide some context with as few confusion as possible + var contextualPath = frame.fileName; + var originalFile = new File(contextualPath); + if (originalFile.isFile()) { + var file = originalFile; + // Do it 3 times, because the first time is to get from the actual file to its folder + for (var i = 0; i < 3; i++) { + file = file.parentFile; + } + contextualPath = file.toPath().relativize(originalFile.toPath()); + } + + out += "in file ".white() + String(contextualPath).red() + " on line ".white() + + String(frame.lineNumber).red(); + out += "\n".white(); + return out; +}; + +// Takes an exception and return a pretty string, removing scriptcraft's internal call stack +// We only keeps the lines which path is in allowedPaths +// This function is mainly used in classroom setting (hence allowedPaths default value) +// However you can pass an optional parameter allowedPaths if you want to override this (if you are +// debugging stuff in a module/plugin for example) +var prettifyException = function(exception, allowedPaths) { + if (typeof exception === "undefined" || typeof exception.getStackTrace != "function") { + console.warn("Called prettifyException without an exception"); + return; + } + if (typeof allowedPaths === "undefined") { + allowedPaths = ["scriptcraft/players/"]; + } + var out = ""; + + // Pretty print the exception message itself + var splitted = String(exception).split(":"); + // This should probably not append + if (splitted.length < 2) { + out += "Error: ".red() + String(exception).white() + "\n"; + } else { + out += splitted[0].red() + ":".white() + splitted[1] + "\n"; + } + + // pretty print the stack now + var frames = []; + if (nashorn) { + frames = utils.array(exception.getStackTrace()); + } else { + console.log("prettifyException needs nashorn engine"); + } + var firstFunction = true; + frames.forEach(function(frame) { + // Check if the frame is relevant + var matching = false; + allowedPaths.forEach(function(path) { + if (frame.fileName.indexOf(path) != -1) + matching = true; + }); + if (matching) { + out += firstFunction ? "" : "Called ".red(); + out += prettifyFrame(frame); + firstFunction = false; + } + // Special case : always display this (it contains the function name called) + if (containsFunctionName(frame)) { + out += prettifyFrame(frame); + } + }); + return out; +}; + +// Make sure we are sending the message to a proper player +var handleException = function(exception, player) { + if (entities.player(player)) { + echo(player, prettifyException(exception)); + } +}; + + +// Wrap an event callback with proper exception handling +// Use it like this : +// events.playerMove(eventWrapper(myHandler)); +var eventWrapper = function(callback) { + var wrapped = function() { + try { + callback.apply(this, arguments); + } catch (exception) { + // Try to send it to someone involved with the event + var event = arguments[0]; + var receivers = findReceivers(event, exception); + // There is no one to listen - let's just send it to every player, someone's bound to + // see the error + if (!receivers) { + receivers = utils.players(); + } + receivers.forEach(function(r) { handleException(exception, r); }); + } + }; + return wrapped; +}; + +// Wrap a function with proper exception handling +// Use it like this : +// exports.myFunc = functionWrapper(myFunc); +var functionWrapper = function(fn) { + var wrapped = function() { + try { + fn.apply(this, arguments); + } catch (exception) { + var sender = self; + handleException(exception, sender); + } + }; + return wrapped; +}; + + +module.exports = { + prettifyException: prettifyException, + handleException: handleException, + eventWrapper: eventWrapper, + functionWrapper: functionWrapper, +}; diff --git a/src/main/js/modules/gnanclass/index.js b/src/main/js/modules/gnanclass/index.js index fb1e5415c..3c35d64b9 100644 --- a/src/main/js/modules/gnanclass/index.js +++ b/src/main/js/modules/gnanclass/index.js @@ -3,6 +3,7 @@ var utils = require('utils'), watcher = require('watcher'), autoload = require('./autoload'), + displayPlayerFunctions = require("clickable-chat").displayPlayerFunctions, foreach = utils.foreach, watchDir = watcher.watchDir, unwatchDir = watcher.unwatchDir, @@ -221,6 +222,7 @@ function startWatching(scriptDir, player) { var _notify = player ? function (msg) { echo(player, msg); + displayPlayerFunctions(player, true); } : function (){}; function _reload() { @@ -308,3 +310,4 @@ if (__plugin.canary){ }, 'HIGHEST'); } module.exports = _gnanclass; + diff --git a/src/main/js/plugins/gnancraft.js b/src/main/js/plugins/gnancraft.js index 3bd9166ed..f5c8c56fe 100644 --- a/src/main/js/plugins/gnancraft.js +++ b/src/main/js/plugins/gnancraft.js @@ -7,6 +7,8 @@ exports.gnanclass = gnanclass; var scedit = require('scedit'); exports.scedit = scedit; +var sendChat = require('clickable-chat').sendChat; + if(__plugin.canary) { events.teleport(function(evt) { if (evt.teleportReason.name() != 'RESPAWN') { return ; } @@ -20,20 +22,6 @@ if(__plugin.canary) { events.playerJoin(welcome); } -function sendChat(player, chatObjects) { - var json = JSON.stringify(chatObjects); - if(__plugin.canary) { - var Canary = Packages.net.canarymod.Canary; - var ccFactory = Canary.factory().getChatComponentFactory(); - var cc = ccFactory.deserialize(json); - player['message(ChatComponent[])'](cc); - } else { - var ComponentSerializer = Packages.net.md_5.bungee.chat.ComponentSerializer; - var cc = ComponentSerializer.parse(json); - player.spigot().sendMessage(cc); - } -} - function showScEditUrl(player) { if (! scedit.enabled()) { return ; } var url = scedit.getUrlFor(player.name); @@ -52,3 +40,4 @@ function showScEditUrl(player) { sendChat(player, chat); }; +