import re import os import sys import bisect import select import simplejson import collections from os import path from django.conf import settings from django.core.management.base import BaseCommand, CommandError ## Un-concatenating source files class LineToFile(object): '''Map line numbers in the concatencated source files to individual file/line pairs.''' def __init__(self): self._names = [] self._cumulative_counts = [] total = 0 for filename in settings.PIPELINE_JS['app']['source_filenames']: self._names.append(filename) self._cumulative_counts.append(total) with open(path.join('zephyr/static', filename), 'r') as fil: total += sum(1 for ln in fil) + 1 def __call__(self, total): i = bisect.bisect_right(self._cumulative_counts, total) - 1 return (self._names[i], total - self._cumulative_counts[i]) line_to_file = LineToFile() ## Parsing source maps # Mapping from Base64 digits to numerical value digits = dict(zip( 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', range(64))) def parse_base64_vlq(input_str): '''Interpret a sequence of Base64 digits as sequence of integers in VLQ encoding.''' accum, shift = 0, 0 for digit in input_str: value = digits[digit] # Low 5 bits provide the next 5 more significant # bits of the output value. accum |= (value & 0b11111) << shift shift += 5 # Top bit is cleared if this is the last digit # for this output value. if not value & 0b100000: # Bottom bit of the result is sign. sign = -1 if accum & 1 else 1 yield sign * (accum >> 1) accum, shift = 0, 0 Link = collections.namedtuple('Link', ['src_line', 'src_col', 'gen_line', 'gen_col']) def parse_mapping(mapstr): '''Parse a source map v3 mapping string into a sequence of 'links' between source and generated code.''' fields = [0,0,0,0,0] for genline_no, group in enumerate(mapstr.split(';')): # The first field (generated code starting column) # resets for every group. fields[0] = 0 for segment in group.split(','): # Each segment contains VLQ-encoded deltas to the fields. delta = list(parse_base64_vlq(segment)) delta += [0] * (5-len(delta)) fields = [x+y for x,y in zip(fields, delta)] # fields[1] indicates which source file produced this # code, but Pipeline concatenates all files together, # so this field is always 0. # Lines and columns are numbered from zero. yield Link(src_line=fields[2], src_col=fields[3], gen_line=genline_no, gen_col=fields[0]) ## Performing the lookup class GenToSrc(object): '''Map (line,column) pairs from generated to source file.''' def __init__(self, sourcemap_file): with open(sourcemap_file, 'r') as fil: sourcemap = simplejson.load(fil) # Pair each link with a sort / search key self._links = [ ((link.gen_line, link.gen_col), link) for link in parse_mapping(sourcemap['mappings']) ] self._links.sort(key = lambda p: p[0]) self._keys = [p[0] for p in self._links] def __call__(self, gen_line, gen_col): i = bisect.bisect_right(self._keys, (gen_line, gen_col)) if not i: # Zero index indicates no match return None link = self._links[i-1][1] filename, src_line = line_to_file(link.src_line) src_col = link.src_col + (gen_col - link.gen_col) return (filename, src_line, src_col) ## UI # Wait for the user to paste text, then time out quickly and # return it. Disable echo so that we can re-echo the same # lines with our annotations. def get_full_paste(): try: os.system('stty -echo raw isig') data = '' while True: fd = sys.stdin.fileno() can_read = select.select([fd], [], [], 0.1)[0] if can_read: data += os.read(fd, 1) else: if data: return data finally: os.system('stty cooked echo') class Command(BaseCommand): args = '' help = '''Add source locations to a stack backtrace generated by minified code. The currently checked out code should match the version that generated the error.''' def handle(self, *args, **options): if len(args) != 1: raise CommandError('No source map file specified') gen_to_src = GenToSrc(args[0]) write = sys.stdout.write if os.isatty(sys.stdin.fileno()): write('Paste stacktrace:\n\n') sys.stdout.flush() lines = get_full_paste().splitlines() else: lines = sys.stdin.readlines() for ln in lines: ln = ln.rstrip() write(ln + '\n') match = re.search(r'/static/min/app(\.[0-9a-f]+)?\.js:(\d+):(\d+)', ln) if match: gen_line, gen_col = map(int, match.groups()[1:3]) result = gen_to_src(gen_line-1, gen_col-1) if result: filename, src_line, src_col = result write(' = %s line %d column %d\n' % (filename, src_line+1, src_col+1)) if ln.startswith(' at'): write('\n')