Skip to content

Commit e72a8c6

Browse files
committed
Merge pull request #1 from smashwilson/trace-parsing
Stacktrace parsing
2 parents 631777e + e12a45f commit e72a8c6

9 files changed

+257
-40
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
2+
module.exports =
3+
4+
recognize: (line, f, {emitMessage, emitFrame, emitStack}) ->
5+
m = line.match /// ^
6+
(.*Error) : # Error name
7+
(.+) # Message
8+
$
9+
///
10+
return unless m?
11+
12+
emitMessage line
13+
14+
consume: (line, f, {emitMessage, emitFrame, emitStack}) ->
15+
m = line.match /// ^
16+
at \s+
17+
([^(]+) # Function name
18+
\(
19+
([^:]+) : # Path
20+
(\d+) : # Line
21+
(\d+) # Column
22+
\)
23+
///
24+
return emitStack() unless m?
25+
26+
f.functionName m[1].trim()
27+
f.path m[2]
28+
f.lineNumber parseInt m[3]
29+
emitFrame()
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
2+
module.exports =
3+
4+
recognize: (line, f, {emitMessage, emitFrame, emitStack}) ->
5+
m = line.match /// ^
6+
([^:]+) : # File path
7+
(\d+) : # Line number
8+
in \s* ` ([^']+) ' # Function name
9+
: \s (.+) # Error message
10+
$
11+
///
12+
return unless m?
13+
14+
f.path m[1]
15+
f.lineNumber parseInt m[2]
16+
f.functionName m[3]
17+
18+
emitMessage m[4]
19+
emitFrame()
20+
21+
consume: (line, f, {emitMessage, emitFrame, emitStack}) ->
22+
m = line.match /// ^
23+
from \s+ # from
24+
([^:]+) : # File path
25+
(\d+) : # Line number
26+
in \s* ` ([^']+) ' # Function name
27+
$
28+
///
29+
return emitStack() unless m?
30+
31+
f.path m[1]
32+
f.lineNumber parseInt m[2]
33+
f.functionName m[3]
34+
emitFrame()

lib/stacktrace.coffee

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,46 @@
11
jsSHA = require 'jssha'
2+
traceParser = null
23

34
PREFIX = 'stacktrace://trace'
45

56
REGISTRY = {}
67

78
# Internal: A heuristically parsed and interpreted stacktrace.
9+
#
810
class Stacktrace
911

1012
constructor: (@frames = [], @message = '') ->
1113

1214
# Internal: Compute the SHA256 checksum of the normalized stacktrace.
15+
#
1316
getChecksum: ->
1417
body = (frame.rawLine for frame in @frames).join()
1518
sha = new jsSHA(body, 'TEXT')
1619
sha.getHash('SHA-256', 'HEX')
1720

1821
# Internal: Generate a URL that can be used to launch or focus a
1922
# {StacktraceView}.
23+
#
2024
getUrl: -> @url ?= "#{PREFIX}/#{@getChecksum()}"
2125

2226
# Internal: Register this trace in a global map by its URL.
27+
#
2328
register: ->
2429
REGISTRY[@getUrl()] = this
2530

2631
# Internal: Remove this trace from the global map if it had previously been
2732
# registered.
33+
#
2834
unregister: ->
2935
delete REGISTRY[@getUrl()]
3036

37+
# Public: Parse zero to many Stacktrace instances from a corpus of text.
38+
#
39+
# text - A raw blob of text.
40+
#
3141
@parse: (text) ->
32-
frames = []
33-
for rawLine in text.split(/\r?\n/)
34-
f = parseRubyFrame(rawLine)
35-
frames.push f if f?
36-
new Stacktrace(frames, frames[0].message)
42+
{traceParser} = require('./trace-parser') unless traceParser?
43+
traceParser(text)
3744

3845
# Internal: Return a registered trace, or null if none match the provided
3946
# URL.
@@ -45,25 +52,10 @@ class Stacktrace
4552
REGISTRY = {}
4653

4754
# Internal: A single stack frame within a {Stacktrace}.
55+
#
4856
class Frame
4957

50-
constructor: (@rawLine, @path, @lineNumber, @functionName, @message = null) ->
51-
52-
53-
# Internal: Parse a Ruby stack frame. This is a simple placeholder until I
54-
# put together a class hierarchy to handle frame recognition and parsing.
55-
parseRubyFrame = (rawLine) ->
56-
m = rawLine.trim().match /// ^
57-
(?:from \s+)? # On all lines but the first
58-
([^:]+) : # File path
59-
(\d+) : # Line number
60-
in \s* ` ([^']+) ' # Function name
61-
(?: : \s (.*))? # Error message, only on the first
62-
///
63-
64-
if m?
65-
[raw, path, lineNumber, functionName, message] = m
66-
new Frame(raw, path, lineNumber, functionName, message)
58+
constructor: (@rawLine, @path, @lineNumber, @functionName) ->
6759

6860
module.exports =
6961
PREFIX: PREFIX

lib/trace-parser.coffee

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
{Stacktrace, Frame} = require './stacktrace'
2+
fs = require 'fs'
3+
path = require 'path'
4+
util = require 'util'
5+
6+
# Internal: Build a Frame instance with a simple DSL.
7+
#
8+
class FrameBuilder
9+
10+
constructor: (@_rawLine) ->
11+
[@_path, @_lineNumber, @_functionName] = []
12+
13+
path: (@_path) ->
14+
15+
lineNumber: (@_lineNumber) ->
16+
17+
functionName: (@_functionName) ->
18+
19+
# Internal: Use the collected information from a FrameBuilder to instantiate a Frame.
20+
#
21+
asFrame = (fb) ->
22+
required = [
23+
{ name: 'rawLine', ok: fb._rawLine? }
24+
{ name: 'path', ok: fb._path? }
25+
{ name: 'lineNumber', ok: fb._lineNumber? }
26+
{ name: 'functionName', ok: fb._functionName? }
27+
]
28+
missing = (r.name for r in required when not r.ok)
29+
30+
unless missing.length is 0
31+
e = new Error("Missing required frame attributes: #{missing.join ', '}")
32+
e.missing = missing
33+
e.rawLine = fb.rawLine
34+
throw e
35+
36+
new Frame(fb._rawLine, fb._path, fb._lineNumber, fb._functionName)
37+
38+
allTracers = null
39+
40+
# Internal: Load stacktrace parsers from the parsers/ directory.
41+
#
42+
loadTracers = ->
43+
allTracers = []
44+
parsersPath = path.resolve(__dirname, 'parsers')
45+
for parserFile in fs.readdirSync(parsersPath)
46+
allTracers.push require(path.join parsersPath, parserFile)
47+
48+
# Internal: Parse zero or more stacktraces from a sample of text.
49+
#
50+
# text - String output sample that may contain one or more stacktraces from a
51+
# supported language.
52+
# tracers - If provided, use only the provided tracer objects. Otherwise, everything in parsers/
53+
# will be loaded and used.
54+
#
55+
# Returns: An Array of Stacktrace objects, in the order in which they occurred
56+
# in the original sample.
57+
#
58+
traceParser = (text, tracers = null) ->
59+
unless tracers?
60+
loadTracers() unless allTracers?
61+
tracers = allTracers
62+
63+
stacks = []
64+
frames = []
65+
message = null
66+
activeTracer = null
67+
68+
finishStacktrace = ->
69+
s = new Stacktrace(frames, message)
70+
stacks.push s
71+
72+
frames = []
73+
message = null
74+
activeTracer = null
75+
76+
for rawLine in text.split(/\r?\n/)
77+
trimmed = rawLine.trim()
78+
79+
# Mid-stack frame.
80+
if activeTracer?
81+
fb = new FrameBuilder(trimmed)
82+
activeTracer.consume trimmed, fb,
83+
emitMessage: (m) -> message = m
84+
emitFrame: -> frames.push asFrame fb
85+
emitStack: finishStacktrace
86+
87+
# Outside of a frame. Attempt to recognize the next trace by emitting at least one frame.
88+
unless activeTracer?
89+
for t in tracers
90+
fb = new FrameBuilder(trimmed)
91+
t.recognize trimmed, fb,
92+
emitMessage: (m) -> message = m
93+
emitFrame: -> frames = [asFrame(fb)]
94+
emitStack: finishStacktrace
95+
if message? or frames.length > 0
96+
activeTracer = t
97+
break
98+
99+
# Finalize the last Stacktrace.
100+
finishStacktrace() if frames.length > 0
101+
102+
stacks
103+
104+
module.exports =
105+
traceParser: traceParser
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{traceParser} = require '../../lib/trace-parser'
2+
coffeeTracer = require '../../lib/parsers/coffeescript-trace-parser'
3+
ts = require '../trace-fixtures'
4+
5+
describe 'coffeeTracer', ->
6+
describe 'recognition', ->
7+
8+
it 'parses a trace from each CoffeeScript fixture', ->
9+
for f in Object.keys(ts.COFFEESCRIPT)
10+
result = traceParser(ts.COFFEESCRIPT[f], [coffeeTracer])
11+
expect(result.length > 0).toBe(true)
12+
13+
it "doesn't parse a trace from any non-CoffeeScript fixture", ->
14+
for k in Object.keys(ts)
15+
if k isnt 'COFFEESCRIPT'
16+
for f in Object.keys(ts[k])
17+
result = traceParser(ts[k][f], [coffeeTracer])
18+
expect(result.length).toBe(0)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{traceParser} = require '../../lib/trace-parser'
2+
rubyTracer = require '../../lib/parsers/ruby-trace-parser'
3+
ts = require '../trace-fixtures'
4+
5+
describe 'rubyTracer', ->
6+
describe 'recognition', ->
7+
8+
it 'parses a trace from each Ruby fixture', ->
9+
for f in Object.keys(ts.RUBY)
10+
result = traceParser(ts.RUBY[f], [rubyTracer])
11+
expect(result.length > 0).toBe(true)
12+
13+
it "doesn't parse a trace from any non-Ruby fixture", ->
14+
for k in Object.keys(ts)
15+
if k isnt 'RUBY'
16+
for f in Object.keys(ts[k])
17+
result = traceParser(ts[k][f], [rubyTracer])
18+
expect(result.length).toBe(0)

spec/stacktrace-spec.coffee

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,50 @@
11
{Stacktrace} = require '../lib/stacktrace'
2-
{RUBY_TRACE} = require './trace-fixtures'
2+
{RUBY: {FUNCTION: TRACE}} = require './trace-fixtures'
33

44
describe 'Stacktrace', ->
55
describe 'with a Ruby trace', ->
66
[trace, checksum] = []
77

88
beforeEach ->
9-
trace = Stacktrace.parse(RUBY_TRACE)
10-
checksum = '3e325af231517f1e4fbe80f70c2c95296250ba80dc4de90bd5ac9c581506d9a6'
9+
[trace] = Stacktrace.parse(TRACE)
10+
checksum = '9528763b5ab8ef052e2400e39d0f32dbe59ffcd06f039adc487f4f956511691f'
1111

1212
describe 'preparation', ->
1313
it 'trims leading and trailing whitespace from each raw line', ->
1414
lines = (frame.rawLine for frame in trace.frames)
1515
expected = [
16-
"/home/smash/tmp/tracer/dir/file1.rb:3:in `innerfunction': Oh shit (RuntimeError)"
17-
"from /home/smash/tmp/tracer/otherdir/file2.rb:5:in `outerfunction'"
18-
"from entry.rb:7:in `toplevel'"
19-
"from entry.rb:10:in `<main>'"
16+
"/home/smash/samples/tracer/otherdir/file2.rb:6:in `block in outerfunction': whoops (RuntimeError)"
17+
"from /home/smash/samples/tracer/dir/file1.rb:3:in `innerfunction'"
18+
"from /home/smash/samples/tracer/otherdir/file2.rb:5:in `outerfunction'"
19+
"from /home/smash/samples/tracer/entry.rb:7:in `toplevel'"
20+
"from /home/smash/samples/tracer/entry.rb:10:in `<main>'"
2021
]
2122
expect(lines).toEqual(expected)
2223

2324
describe 'parsing a Ruby stack trace', ->
2425
it 'parses the error message', ->
25-
expect(trace.message).toBe('Oh shit (RuntimeError)')
26+
expect(trace.message).toBe('whoops (RuntimeError)')
2627

2728
it 'parses file paths from each frame', ->
2829
filePaths = (frame.path for frame in trace.frames)
2930
expected = [
30-
'/home/smash/tmp/tracer/dir/file1.rb'
31-
'/home/smash/tmp/tracer/otherdir/file2.rb'
32-
'entry.rb'
33-
'entry.rb'
31+
'/home/smash/samples/tracer/otherdir/file2.rb'
32+
'/home/smash/samples/tracer/dir/file1.rb'
33+
'/home/smash/samples/tracer/otherdir/file2.rb'
34+
'/home/smash/samples/tracer/entry.rb'
35+
'/home/smash/samples/tracer/entry.rb'
3436
]
3537
expect(filePaths).toEqual(expected)
3638

3739
it 'parses line numbers from each frame', ->
3840
lineNumbers = (frame.lineNumber for frame in trace.frames)
39-
expected = [3, 5, 7, 10]
41+
expected = [6, 3, 5, 7, 10]
4042
expect(lineNumbers).toEqual(lineNumbers)
4143

4244
it 'parses function names from each frame', ->
4345
functionNames = (frame.functionName for frame in trace.frames)
4446
expected = [
47+
'block in outerfunction'
4548
'innerfunction'
4649
'outerfunction'
4750
'toplevel'

spec/trace-fixtures.coffee

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
# Stack traces shared among several specs.
22

33
module.exports =
4-
RUBY_TRACE: """
5-
/home/smash/tmp/tracer/dir/file1.rb:3:in `innerfunction': Oh shit (RuntimeError)
6-
from /home/smash/tmp/tracer/otherdir/file2.rb:5:in `outerfunction'
7-
from entry.rb:7:in `toplevel'
8-
from entry.rb:10:in `<main>'
4+
RUBY:
5+
FUNCTION: """
6+
/home/smash/samples/tracer/otherdir/file2.rb:6:in `block in outerfunction': whoops (RuntimeError)
7+
from /home/smash/samples/tracer/dir/file1.rb:3:in `innerfunction'
8+
from /home/smash/samples/tracer/otherdir/file2.rb:5:in `outerfunction'
9+
from /home/smash/samples/tracer/entry.rb:7:in `toplevel'
10+
from /home/smash/samples/tracer/entry.rb:10:in `<main>'
911
"""
12+
COFFEESCRIPT:
13+
ERROR: """
14+
Error: yep
15+
at asFrame (/home/smash/code/stacktrace/lib/trace-parser.coffee:36:13)
16+
at t.recognize.emitFrame (/home/smash/code/stacktrace/lib/trace-parser.coffee:95:35)
17+
at Object.module.exports.recognize (/home/smash/code/stacktrace/lib/parsers/ruby-trace-parser.coffee:19:5)
18+
at traceParser (/home/smash/code/stacktrace/lib/trace-parser.coffee:93:11)
19+
at Function.Stacktrace.parse (/home/smash/code/stacktrace/lib/stacktrace.coffee:43:5)
20+
at [object Object].<anonymous> (/home/smash/code/stacktrace/spec/stacktrace-spec.coffee:9:28)
21+
"""

spec/trace-parser-spec.coffee

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{traceParser} = require '../lib/trace-parser'
2+
3+
describe 'traceParser', ->
4+
describe 'with no traces', ->
5+
it 'returns an empty array', ->
6+
expect(traceParser('')).toEqual([])

0 commit comments

Comments
 (0)