diff --git a/lib/parsers/coffeescript-trace-parser.coffee b/lib/parsers/coffeescript-trace-parser.coffee new file mode 100644 index 0000000..4b70b11 --- /dev/null +++ b/lib/parsers/coffeescript-trace-parser.coffee @@ -0,0 +1,29 @@ + +module.exports = + + recognize: (line, f, {emitMessage, emitFrame, emitStack}) -> + m = line.match /// ^ + (.*Error) : # Error name + (.+) # Message + $ + /// + return unless m? + + emitMessage line + + consume: (line, f, {emitMessage, emitFrame, emitStack}) -> + m = line.match /// ^ + at \s+ + ([^(]+) # Function name + \( + ([^:]+) : # Path + (\d+) : # Line + (\d+) # Column + \) + /// + return emitStack() unless m? + + f.functionName m[1].trim() + f.path m[2] + f.lineNumber parseInt m[3] + emitFrame() diff --git a/lib/parsers/ruby-trace-parser.coffee b/lib/parsers/ruby-trace-parser.coffee new file mode 100644 index 0000000..8d466fb --- /dev/null +++ b/lib/parsers/ruby-trace-parser.coffee @@ -0,0 +1,34 @@ + +module.exports = + + recognize: (line, f, {emitMessage, emitFrame, emitStack}) -> + m = line.match /// ^ + ([^:]+) : # File path + (\d+) : # Line number + in \s* ` ([^']+) ' # Function name + : \s (.+) # Error message + $ + /// + return unless m? + + f.path m[1] + f.lineNumber parseInt m[2] + f.functionName m[3] + + emitMessage m[4] + emitFrame() + + consume: (line, f, {emitMessage, emitFrame, emitStack}) -> + m = line.match /// ^ + from \s+ # from + ([^:]+) : # File path + (\d+) : # Line number + in \s* ` ([^']+) ' # Function name + $ + /// + return emitStack() unless m? + + f.path m[1] + f.lineNumber parseInt m[2] + f.functionName m[3] + emitFrame() diff --git a/lib/stacktrace.coffee b/lib/stacktrace.coffee index c43cc36..cfa23fe 100644 --- a/lib/stacktrace.coffee +++ b/lib/stacktrace.coffee @@ -1,15 +1,18 @@ jsSHA = require 'jssha' +traceParser = null PREFIX = 'stacktrace://trace' REGISTRY = {} # Internal: A heuristically parsed and interpreted stacktrace. +# class Stacktrace constructor: (@frames = [], @message = '') -> # Internal: Compute the SHA256 checksum of the normalized stacktrace. + # getChecksum: -> body = (frame.rawLine for frame in @frames).join() sha = new jsSHA(body, 'TEXT') @@ -17,23 +20,27 @@ class Stacktrace # Internal: Generate a URL that can be used to launch or focus a # {StacktraceView}. + # getUrl: -> @url ?= "#{PREFIX}/#{@getChecksum()}" # Internal: Register this trace in a global map by its URL. + # register: -> REGISTRY[@getUrl()] = this # Internal: Remove this trace from the global map if it had previously been # registered. + # unregister: -> delete REGISTRY[@getUrl()] + # Public: Parse zero to many Stacktrace instances from a corpus of text. + # + # text - A raw blob of text. + # @parse: (text) -> - frames = [] - for rawLine in text.split(/\r?\n/) - f = parseRubyFrame(rawLine) - frames.push f if f? - new Stacktrace(frames, frames[0].message) + {traceParser} = require('./trace-parser') unless traceParser? + traceParser(text) # Internal: Return a registered trace, or null if none match the provided # URL. @@ -45,25 +52,10 @@ class Stacktrace REGISTRY = {} # Internal: A single stack frame within a {Stacktrace}. +# class Frame - constructor: (@rawLine, @path, @lineNumber, @functionName, @message = null) -> - - -# Internal: Parse a Ruby stack frame. This is a simple placeholder until I -# put together a class hierarchy to handle frame recognition and parsing. -parseRubyFrame = (rawLine) -> - m = rawLine.trim().match /// ^ - (?:from \s+)? # On all lines but the first - ([^:]+) : # File path - (\d+) : # Line number - in \s* ` ([^']+) ' # Function name - (?: : \s (.*))? # Error message, only on the first - /// - - if m? - [raw, path, lineNumber, functionName, message] = m - new Frame(raw, path, lineNumber, functionName, message) + constructor: (@rawLine, @path, @lineNumber, @functionName) -> module.exports = PREFIX: PREFIX diff --git a/lib/trace-parser.coffee b/lib/trace-parser.coffee new file mode 100644 index 0000000..43dbfe8 --- /dev/null +++ b/lib/trace-parser.coffee @@ -0,0 +1,105 @@ +{Stacktrace, Frame} = require './stacktrace' +fs = require 'fs' +path = require 'path' +util = require 'util' + +# Internal: Build a Frame instance with a simple DSL. +# +class FrameBuilder + + constructor: (@_rawLine) -> + [@_path, @_lineNumber, @_functionName] = [] + + path: (@_path) -> + + lineNumber: (@_lineNumber) -> + + functionName: (@_functionName) -> + +# Internal: Use the collected information from a FrameBuilder to instantiate a Frame. +# +asFrame = (fb) -> + required = [ + { name: 'rawLine', ok: fb._rawLine? } + { name: 'path', ok: fb._path? } + { name: 'lineNumber', ok: fb._lineNumber? } + { name: 'functionName', ok: fb._functionName? } + ] + missing = (r.name for r in required when not r.ok) + + unless missing.length is 0 + e = new Error("Missing required frame attributes: #{missing.join ', '}") + e.missing = missing + e.rawLine = fb.rawLine + throw e + + new Frame(fb._rawLine, fb._path, fb._lineNumber, fb._functionName) + +allTracers = null + +# Internal: Load stacktrace parsers from the parsers/ directory. +# +loadTracers = -> + allTracers = [] + parsersPath = path.resolve(__dirname, 'parsers') + for parserFile in fs.readdirSync(parsersPath) + allTracers.push require(path.join parsersPath, parserFile) + +# Internal: Parse zero or more stacktraces from a sample of text. +# +# text - String output sample that may contain one or more stacktraces from a +# supported language. +# tracers - If provided, use only the provided tracer objects. Otherwise, everything in parsers/ +# will be loaded and used. +# +# Returns: An Array of Stacktrace objects, in the order in which they occurred +# in the original sample. +# +traceParser = (text, tracers = null) -> + unless tracers? + loadTracers() unless allTracers? + tracers = allTracers + + stacks = [] + frames = [] + message = null + activeTracer = null + + finishStacktrace = -> + s = new Stacktrace(frames, message) + stacks.push s + + frames = [] + message = null + activeTracer = null + + for rawLine in text.split(/\r?\n/) + trimmed = rawLine.trim() + + # Mid-stack frame. + if activeTracer? + fb = new FrameBuilder(trimmed) + activeTracer.consume trimmed, fb, + emitMessage: (m) -> message = m + emitFrame: -> frames.push asFrame fb + emitStack: finishStacktrace + + # Outside of a frame. Attempt to recognize the next trace by emitting at least one frame. + unless activeTracer? + for t in tracers + fb = new FrameBuilder(trimmed) + t.recognize trimmed, fb, + emitMessage: (m) -> message = m + emitFrame: -> frames = [asFrame(fb)] + emitStack: finishStacktrace + if message? or frames.length > 0 + activeTracer = t + break + + # Finalize the last Stacktrace. + finishStacktrace() if frames.length > 0 + + stacks + +module.exports = + traceParser: traceParser diff --git a/spec/parsers/coffeescript-trace-parser-spec.coffee b/spec/parsers/coffeescript-trace-parser-spec.coffee new file mode 100644 index 0000000..707c57c --- /dev/null +++ b/spec/parsers/coffeescript-trace-parser-spec.coffee @@ -0,0 +1,18 @@ +{traceParser} = require '../../lib/trace-parser' +coffeeTracer = require '../../lib/parsers/coffeescript-trace-parser' +ts = require '../trace-fixtures' + +describe 'coffeeTracer', -> + describe 'recognition', -> + + it 'parses a trace from each CoffeeScript fixture', -> + for f in Object.keys(ts.COFFEESCRIPT) + result = traceParser(ts.COFFEESCRIPT[f], [coffeeTracer]) + expect(result.length > 0).toBe(true) + + it "doesn't parse a trace from any non-CoffeeScript fixture", -> + for k in Object.keys(ts) + if k isnt 'COFFEESCRIPT' + for f in Object.keys(ts[k]) + result = traceParser(ts[k][f], [coffeeTracer]) + expect(result.length).toBe(0) diff --git a/spec/parsers/ruby-trace-parser-spec.coffee b/spec/parsers/ruby-trace-parser-spec.coffee new file mode 100644 index 0000000..9bc0bdc --- /dev/null +++ b/spec/parsers/ruby-trace-parser-spec.coffee @@ -0,0 +1,18 @@ +{traceParser} = require '../../lib/trace-parser' +rubyTracer = require '../../lib/parsers/ruby-trace-parser' +ts = require '../trace-fixtures' + +describe 'rubyTracer', -> + describe 'recognition', -> + + it 'parses a trace from each Ruby fixture', -> + for f in Object.keys(ts.RUBY) + result = traceParser(ts.RUBY[f], [rubyTracer]) + expect(result.length > 0).toBe(true) + + it "doesn't parse a trace from any non-Ruby fixture", -> + for k in Object.keys(ts) + if k isnt 'RUBY' + for f in Object.keys(ts[k]) + result = traceParser(ts[k][f], [rubyTracer]) + expect(result.length).toBe(0) diff --git a/spec/stacktrace-spec.coffee b/spec/stacktrace-spec.coffee index 2dbe3cb..889de8c 100644 --- a/spec/stacktrace-spec.coffee +++ b/spec/stacktrace-spec.coffee @@ -1,47 +1,50 @@ {Stacktrace} = require '../lib/stacktrace' -{RUBY_TRACE} = require './trace-fixtures' +{RUBY: {FUNCTION: TRACE}} = require './trace-fixtures' describe 'Stacktrace', -> describe 'with a Ruby trace', -> [trace, checksum] = [] beforeEach -> - trace = Stacktrace.parse(RUBY_TRACE) - checksum = '3e325af231517f1e4fbe80f70c2c95296250ba80dc4de90bd5ac9c581506d9a6' + [trace] = Stacktrace.parse(TRACE) + checksum = '9528763b5ab8ef052e2400e39d0f32dbe59ffcd06f039adc487f4f956511691f' describe 'preparation', -> it 'trims leading and trailing whitespace from each raw line', -> lines = (frame.rawLine for frame in trace.frames) expected = [ - "/home/smash/tmp/tracer/dir/file1.rb:3:in `innerfunction': Oh shit (RuntimeError)" - "from /home/smash/tmp/tracer/otherdir/file2.rb:5:in `outerfunction'" - "from entry.rb:7:in `toplevel'" - "from entry.rb:10:in `
'" + "/home/smash/samples/tracer/otherdir/file2.rb:6:in `block in outerfunction': whoops (RuntimeError)" + "from /home/smash/samples/tracer/dir/file1.rb:3:in `innerfunction'" + "from /home/smash/samples/tracer/otherdir/file2.rb:5:in `outerfunction'" + "from /home/smash/samples/tracer/entry.rb:7:in `toplevel'" + "from /home/smash/samples/tracer/entry.rb:10:in `
'" ] expect(lines).toEqual(expected) describe 'parsing a Ruby stack trace', -> it 'parses the error message', -> - expect(trace.message).toBe('Oh shit (RuntimeError)') + expect(trace.message).toBe('whoops (RuntimeError)') it 'parses file paths from each frame', -> filePaths = (frame.path for frame in trace.frames) expected = [ - '/home/smash/tmp/tracer/dir/file1.rb' - '/home/smash/tmp/tracer/otherdir/file2.rb' - 'entry.rb' - 'entry.rb' + '/home/smash/samples/tracer/otherdir/file2.rb' + '/home/smash/samples/tracer/dir/file1.rb' + '/home/smash/samples/tracer/otherdir/file2.rb' + '/home/smash/samples/tracer/entry.rb' + '/home/smash/samples/tracer/entry.rb' ] expect(filePaths).toEqual(expected) it 'parses line numbers from each frame', -> lineNumbers = (frame.lineNumber for frame in trace.frames) - expected = [3, 5, 7, 10] + expected = [6, 3, 5, 7, 10] expect(lineNumbers).toEqual(lineNumbers) it 'parses function names from each frame', -> functionNames = (frame.functionName for frame in trace.frames) expected = [ + 'block in outerfunction' 'innerfunction' 'outerfunction' 'toplevel' diff --git a/spec/trace-fixtures.coffee b/spec/trace-fixtures.coffee index 1c9970a..e516aec 100644 --- a/spec/trace-fixtures.coffee +++ b/spec/trace-fixtures.coffee @@ -1,9 +1,21 @@ # Stack traces shared among several specs. module.exports = - RUBY_TRACE: """ - /home/smash/tmp/tracer/dir/file1.rb:3:in `innerfunction': Oh shit (RuntimeError) - from /home/smash/tmp/tracer/otherdir/file2.rb:5:in `outerfunction' - from entry.rb:7:in `toplevel' - from entry.rb:10:in `
' + RUBY: + FUNCTION: """ + /home/smash/samples/tracer/otherdir/file2.rb:6:in `block in outerfunction': whoops (RuntimeError) + from /home/smash/samples/tracer/dir/file1.rb:3:in `innerfunction' + from /home/smash/samples/tracer/otherdir/file2.rb:5:in `outerfunction' + from /home/smash/samples/tracer/entry.rb:7:in `toplevel' + from /home/smash/samples/tracer/entry.rb:10:in `
' """ + COFFEESCRIPT: + ERROR: """ + Error: yep + at asFrame (/home/smash/code/stacktrace/lib/trace-parser.coffee:36:13) + at t.recognize.emitFrame (/home/smash/code/stacktrace/lib/trace-parser.coffee:95:35) + at Object.module.exports.recognize (/home/smash/code/stacktrace/lib/parsers/ruby-trace-parser.coffee:19:5) + at traceParser (/home/smash/code/stacktrace/lib/trace-parser.coffee:93:11) + at Function.Stacktrace.parse (/home/smash/code/stacktrace/lib/stacktrace.coffee:43:5) + at [object Object]. (/home/smash/code/stacktrace/spec/stacktrace-spec.coffee:9:28) + """ diff --git a/spec/trace-parser-spec.coffee b/spec/trace-parser-spec.coffee new file mode 100644 index 0000000..a9d10d1 --- /dev/null +++ b/spec/trace-parser-spec.coffee @@ -0,0 +1,6 @@ +{traceParser} = require '../lib/trace-parser' + +describe 'traceParser', -> + describe 'with no traces', -> + it 'returns an empty array', -> + expect(traceParser('')).toEqual([])