Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions lib/parsers/coffeescript-trace-parser.coffee
Original file line number Diff line number Diff line change
@@ -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()
34 changes: 34 additions & 0 deletions lib/parsers/ruby-trace-parser.coffee
Original file line number Diff line number Diff line change
@@ -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()
36 changes: 14 additions & 22 deletions lib/stacktrace.coffee
Original file line number Diff line number Diff line change
@@ -1,39 +1,46 @@
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')
sha.getHash('SHA-256', 'HEX')

# 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.
Expand All @@ -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
Expand Down
105 changes: 105 additions & 0 deletions lib/trace-parser.coffee
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions spec/parsers/coffeescript-trace-parser-spec.coffee
Original file line number Diff line number Diff line change
@@ -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)
18 changes: 18 additions & 0 deletions spec/parsers/ruby-trace-parser-spec.coffee
Original file line number Diff line number Diff line change
@@ -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)
29 changes: 16 additions & 13 deletions spec/stacktrace-spec.coffee
Original file line number Diff line number Diff line change
@@ -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 `<main>'"
"/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 `<main>'"
]
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'
Expand Down
22 changes: 17 additions & 5 deletions spec/trace-fixtures.coffee
Original file line number Diff line number Diff line change
@@ -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 `<main>'
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 `<main>'
"""
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].<anonymous> (/home/smash/code/stacktrace/spec/stacktrace-spec.coffee:9:28)
"""
6 changes: 6 additions & 0 deletions spec/trace-parser-spec.coffee
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{traceParser} = require '../lib/trace-parser'

describe 'traceParser', ->
describe 'with no traces', ->
it 'returns an empty array', ->
expect(traceParser('')).toEqual([])