diff --git a/README.md b/README.md index 3e47066..bc1d3c9 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,20 @@ haxelib install jstack ## Usage Just add JStack to compilation with `-lib jstack` compiler flag. +## Clickable positions in stack traces. + +If your IDE supports clickable file links in app output, you can specify a pattern for call stack entries: +```haxe +-D JSTACK_FORMAT=%symbol% at %file%:%line% +//or predefined pattern for VSCode +-D JSTACK_FORMAT=vscode +//or predefined pattern for IntelliJ IDEA +-D JSTACK_FORMAT=idea +``` +![](http://i.imgur.com/OgRnQOI.gif) + +## Custom entry point + If you don't have `-main` in your build config, then you need to specify entry point like this: ``` -D JSTACK_MAIN=my.SomeClass.entryPoint diff --git a/extraParams.hxml b/extraParams.hxml index 7dc4aab..b2aece7 100644 --- a/extraParams.hxml +++ b/extraParams.hxml @@ -1,3 +1,3 @@ --macro include('jstack.JStack') --macro keep('jstack.JStack') ---macro jstack.Tools.addInjectMetaToEntryPoint() \ No newline at end of file +--macro jstack.Tools.initialize() \ No newline at end of file diff --git a/format/js/haxe/CallStack.hx b/format/js/haxe/CallStack.hx new file mode 100644 index 0000000..71d3621 --- /dev/null +++ b/format/js/haxe/CallStack.hx @@ -0,0 +1,102 @@ +package haxe; + +enum StackItem { + CFunction; + Module( m : String ); + FilePos( s : Null, file : String, line : Int ); + Method( classname : String, method : String ); + LocalFunction( ?v : Int ); +} + +/** + Get information about the call stack. +**/ +class CallStack { + static var lastException:js.Error; + + static function getStack(e:js.Error):Array { + if (e == null) return []; + // https://code.google.com/p/v8/wiki/JavaScriptStackTraceApi + var oldValue = (untyped Error).prepareStackTrace; + (untyped Error).prepareStackTrace = function (error, callsites :Array) { + var stack = []; + for (site in callsites) { + if (wrapCallSite != null) site = wrapCallSite(site); + var method = null; + var fullName :String = site.getFunctionName(); + if (fullName != null) { + var idx = fullName.lastIndexOf("."); + if (idx >= 0) { + var className = fullName.substr(0, idx); + var methodName = fullName.substr(idx+1); + method = Method(className, methodName); + } + } + stack.push(FilePos(method, site.getFileName(), site.getLineNumber())); + } + return stack; + } + var a = makeStack(e.stack); + (untyped Error).prepareStackTrace = oldValue; + return a; + } + + // support for source-map-support module + @:noCompletion + public static var wrapCallSite:Dynamic->Dynamic; + + /** + Return the call stack elements, or an empty array if not available. + **/ + public static function callStack() : Array { + try { + throw new js.Error(); + } catch( e : Dynamic ) { + var a = getStack(e); + a.shift(); // remove Stack.callStack() + return a; + } + } + + /** + Return the exception stack : this is the stack elements between + the place the last exception was thrown and the place it was + caught, or an empty array if not available. + **/ + public static function exceptionStack() : Array { + return untyped __define_feature__("haxe.CallStack.exceptionStack", getStack(lastException)); + } + + /** + Returns a representation of the stack as a printable string. + **/ + public static function toString( stack : Array ) { + return jstack.Format.toString(stack); + } + + private static function makeStack(s #if cs : cs.system.diagnostics.StackTrace #elseif hl : hl.NativeArray #end) { + if (s == null) { + return []; + } else if ((untyped __js__("typeof"))(s) == "string") { + // Return the raw lines in browsers that don't support prepareStackTrace + var stack : Array = s.split("\n"); + if( stack[0] == "Error" ) stack.shift(); + var m = []; + var rie10 = ~/^ at ([A-Za-z0-9_. ]+) \(([^)]+):([0-9]+):([0-9]+)\)$/; + for( line in stack ) { + if( rie10.match(line) ) { + var path = rie10.matched(1).split("."); + var meth = path.pop(); + var file = rie10.matched(2); + var line = Std.parseInt(rie10.matched(3)); + m.push(FilePos( meth == "Anonymous function" ? LocalFunction() : meth == "Global code" ? null : Method(path.join("."),meth), file, line )); + } else + m.push(Module(StringTools.trim(line))); // A little weird, but better than nothing + } + return m; + } else { + return cast s; + } + } + +} diff --git a/format/php7/haxe/CallStack.hx b/format/php7/haxe/CallStack.hx new file mode 100644 index 0000000..7103192 --- /dev/null +++ b/format/php7/haxe/CallStack.hx @@ -0,0 +1,125 @@ +package haxe; + +import php.*; + +private typedef NativeTrace = NativeIndexedArray>; + +/** + Elements return by `CallStack` methods. +**/ +enum StackItem { + CFunction; + Module( m : String ); + FilePos( s : Null, file : String, line : Int ); + Method( classname : String, method : String ); + LocalFunction( ?v : Int ); +} + +class CallStack { + /** + If defined this function will be used to transform call stack entries. + @param String - generated php file name. + @param Int - Line number in generated file. + */ + static public var mapPosition : String->Int->Null<{?source:String, ?originalLine:Int}>; + + @:ifFeature("haxe.CallStack.exceptionStack") + static var lastExceptionTrace : NativeTrace; + + /** + Return the call stack elements, or an empty array if not available. + **/ + public static function callStack() : Array { + return makeStack(Global.debug_backtrace(Const.DEBUG_BACKTRACE_IGNORE_ARGS)); + } + + /** + Return the exception stack : this is the stack elements between + the place the last exception was thrown and the place it was + caught, or an empty array if not available. + **/ + public static function exceptionStack() : Array { + return makeStack(lastExceptionTrace == null ? new NativeIndexedArray() : lastExceptionTrace); + } + + /** + Returns a representation of the stack as a printable string. + **/ + public static function toString( stack : Array ) { + return jstack.Format.toString(stack); + } + + @:ifFeature("haxe.CallStack.exceptionStack") + static function saveExceptionTrace( e:Throwable ) : Void { + lastExceptionTrace = e.getTrace(); + + //Reduce exception stack to the place where exception was caught + var currentTrace = Global.debug_backtrace(Const.DEBUG_BACKTRACE_IGNORE_ARGS); + var count = Global.count(currentTrace); + + for (i in -(count - 1)...1) { + var exceptionEntry:NativeAssocArray = Global.end(lastExceptionTrace); + + if(!Global.isset(exceptionEntry['file']) || !Global.isset(currentTrace[-i]['file'])) { + Global.array_pop(lastExceptionTrace); + } else if (currentTrace[-i]['file'] == exceptionEntry['file'] && currentTrace[-i]['line'] == exceptionEntry['line']) { + Global.array_pop(lastExceptionTrace); + } else { + break; + } + } + + //Remove arguments from trace to avoid blocking some objects from GC + var count = Global.count(lastExceptionTrace); + for (i in 0...count) { + lastExceptionTrace[i]['args'] = new NativeArray(); + } + + var thrownAt = new NativeAssocArray(); + thrownAt['function'] = ''; + thrownAt['line'] = e.getLine(); + thrownAt['file'] = e.getFile(); + thrownAt['class'] = ''; + thrownAt['args'] = new NativeArray(); + Global.array_unshift(lastExceptionTrace, thrownAt); + } + + static function makeStack (native:NativeTrace) : Array { + var result = []; + var count = Global.count(native); + + for (i in 0...count) { + var entry = native[i]; + var item = null; + + if (i + 1 < count) { + var next = native[i + 1]; + + if(!Global.isset(next['function'])) next['function'] = ''; + if(!Global.isset(next['class'])) next['class'] = ''; + + if ((next['function']:String).indexOf('{closure}') >= 0) { + item = LocalFunction(); + } else if ((next['class']:String).length > 0 && (next['function']:String).length > 0) { + var cls = Boot.getClassName(next['class']); + item = Method(cls, next['function']); + } + } + if (Global.isset(entry['file'])) { + if (mapPosition != null) { + var pos = mapPosition(entry['file'], entry['line']); + if (pos != null && pos.source != null && pos.originalLine != null) { + entry['file'] = pos.source; + entry['line'] = pos.originalLine; + } + } + result.push(FilePos(item, entry['file'], entry['line'])); + } else if (item != null) { + result.push(item); + } + } + + return result; + } + +} \ No newline at end of file diff --git a/haxelib.json b/haxelib.json index 704a695..dde5e49 100644 --- a/haxelib.json +++ b/haxelib.json @@ -4,8 +4,8 @@ "license" : "MIT", "tags" : ["js", "php7", "stack", "callstack", "stacktrace"], "description" : "Friendly stack traces for JS and PHP7 targets. Makes them point to haxe sources.", - "version" : "2.2.1", - "releasenote" : "Try harder to map stack of uncaught exceptions on nodejs.", + "version" : "2.3.0", + "releasenote" : "-D JSTACK_FORMAT for IDE-friendly stack traces (see Readme).", "classPath" : "src", "contributors" : ["RealyUniqueName"], "dependencies" : { diff --git a/src/jstack/Format.hx b/src/jstack/Format.hx new file mode 100644 index 0000000..b3d8dba --- /dev/null +++ b/src/jstack/Format.hx @@ -0,0 +1,70 @@ +package jstack; + +import haxe.CallStack.StackItem; + +using StringTools; + +/** + Call stack formatting utils. +**/ +class Format { + public static function toString (stack:Array) : String { + var format = Tools.getFormat(); + var buf = new StringBuf(); + for (item in stack) { + if(format == null) { + buf.add('\nCalled from '); + itemToString(buf, item); + } else { + buf.add('\n'); + buf.add(itemToFormat(format, item)); + } + } + return buf.toString(); + } + + static function itemToString (buf:StringBuf, item:StackItem) { + switch (item) { + case CFunction: + buf.add('a C function'); + case Module(m): + buf.add('module '); + buf.add(m); + case FilePos(item, file, line): + if( item != null ) { + itemToString(buf, item); + buf.add(' ('); + } + buf.add(file); + buf.add(' line '); + buf.add(line); + if (item != null) buf.add(')'); + case Method(cname,meth): + buf.add(cname); + buf.add('.'); + buf.add(meth); + case LocalFunction(n): + buf.add('local function #'); + buf.add(n); + } + } + + static function itemToFormat (format:String, item:StackItem) : String { + switch (item) { + case CFunction: + return 'a C function'; + case Module(m): + return 'module $m'; + case FilePos(s,file,line): + if(file.substr(0, 'file://'.length) == 'file://') { + file = file.substr('file://'.length); + } + var symbol = (s == null ? '' : itemToFormat(format, s)); + return format.replace('%file%', file).replace('%line%', '$line').replace('%symbol%', symbol); + case Method(cname,meth): + return '$cname.$meth'; + case LocalFunction(n): + return 'local function #$n'; + } + } +} \ No newline at end of file diff --git a/src/jstack/Tools.hx b/src/jstack/Tools.hx index 53c3aea..36b75fa 100644 --- a/src/jstack/Tools.hx +++ b/src/jstack/Tools.hx @@ -21,14 +21,31 @@ class Tools { static private inline var SOURCE_MAP_LIB_FILE = '../../js/source-map.min.js'; /** - Inject `json.JStack.onReady()` into app entry point, so that app will not start untill source map is ready. + Initialization macro. To be called with `--macro` **/ - static public function addInjectMetaToEntryPoint() : Void - { + static public function initialize () { #if (display || !(debug || JSTACK_FORCE)) return; #end if (!Context.defined('js') && !Context.defined('php7')) return; + + if (Context.defined('JSTACK_FORMAT')) { + if(Context.defined('js')) { + Compiler.addClassPath(getJstackRootDir() + 'format/js'); + } else if(Context.defined('php7')) { + Compiler.addClassPath(getJstackRootDir() + 'format/php7'); + } else { + throw 'Unexpected behavior'; + } + } + + addInjectMetaToEntryPoint(); + } + + /** + Inject `json.JStack.onReady()` into app entry point, so that app will not start untill source map is ready. + **/ + static public function addInjectMetaToEntryPoint() : Void { Compiler.define('js_source_map'); Compiler.define('source_map'); @@ -68,10 +85,18 @@ class Tools { Compiler.addMetadata('@:build(jstack.Tools.injectInEntryPoint("$entryMethod"))', entryClass); } + + /** + Get root directory of JStack haxelib. + **/ + static function getJstackRootDir () : String { + var toolsFile = Context.getPosInfos((macro {}).pos).file; + toolsFile.replace('\\', '/'); + return toolsFile.split('/').slice(0, -3).join('/') + '/'; + } #end - macro static public function injectInEntryPoint(method:String) : Array - { + macro static public function injectInEntryPoint(method:String) : Array { var fields = Context.getBuildFields(); var injected = false; @@ -97,34 +122,34 @@ class Tools { /** * Returns file name of generated output */ - macro static public function getOutputFileName () : ExprOf - { + macro static public function getOutputFileName () : ExprOf { var file = Compiler.getOutput().withoutDirectory(); return macro $v{file}; } - /** * Get source map file name for current app */ - macro static public function getSourceMapFileName () : ExprOf - { + macro static public function getSourceMapFileName () : ExprOf { var file = Compiler.getOutput().withoutDirectory(); return macro $v{file} + '.map'; } - - // /** - // * Embeds source-map js library into compiled file - // */ - // macro static public function embedSourceMapLib () : Expr - // { - // var dir = Context.currentPos().getPosInfos().file.directory(); - // var libFile = dir + '/' + SOURCE_MAP_LIB_FILE; - // var libCode = libFile.getContent(); - - // return macro untyped __js__($v{libCode}); - // } + /** + Returns a template for formatting entries in call stack. + Supported placeholders: %file% %line% %symbol% + **/ + macro static public function getFormat () : ExprOf { + return switch (Context.definedValue('JSTACK_FORMAT')) { + case 'vscode': + switch (Sys.systemName()) { + case 'Windows': macro 'Called from %symbol% file://%file%#%line%'; + case _: macro 'Called from %symbol% file://%file%:%line%'; + } + case 'idea': macro '%file%:%line% in %symbol%'; + case format: macro $v{format}; + } + } }