#!/usr/bin/env python

# Copyright 2016 The Fuchsia Authors
#
# Use of this source code is governed by a MIT-style
# license that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT

"""

This tool will symbolize a crash from Magenta's crash logger, adding
function names and, if available, source code locations (filenames and
line numbers from debug info).

Example usage #1:
  ./scripts/run-magenta -a x86-64 | ./scripts/symbolize devmgr.elf --build-dir=build-magenta-pc-x86-64

Example usage #2:
  ./scripts/symbolize devmgr.elf --build-dir=build-magenta-pc-x86-64
  <copy and paste output from Magenta>

"""

import argparse
import os
import re
import subprocess
import sys

SCRIPT_DIR = os.path.abspath(os.path.dirname(__file__))
PREBUILTS_BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(SCRIPT_DIR), "prebuilt",
                                                  "downloads"))

name_to_full_path = {}


def find_func(find_args, dirname, names):
    if find_args["path"] != "":  # found something!
        return
    if dirname.find("sysroot") != -1:
        return
    for name in names:
        if name == find_args["name"]:
            find_args["path"] = dirname
            return


def find_file_in_build_dir(name, build_dirs):
    find_args = {"name": name, "path": ""}
    found_path = ""
    for location in build_dirs:
        os.path.walk(location, find_func, find_args)
        if find_args["path"] != "":
            found_path = os.path.abspath(os.path.join(find_args["path"], name))
            break
    return found_path


def buildid_to_full_path(buildid, build_dirs):
    for build_dir in build_dirs:
        id_file_path = os.path.join(build_dir, "ids.txt")
        if os.path.exists(id_file_path):
            with open(id_file_path) as id_file:
                for line in id_file:
                    id, path = line.split()
                    if id == buildid:
                        return path
    return None


def find_dso_full_path(dso, exe_name, name_to_buildid, build_dirs):
    if dso in name_to_full_path:
        return name_to_full_path[dso]
    if dso in name_to_buildid:
        found_path = buildid_to_full_path(name_to_buildid[dso], build_dirs)
        if found_path:
            name_to_full_path[dso] = found_path
            return found_path
    # The name 'app' indicates the real app name is unknown.
    # If the process has a name property that will be printed, but
    # it has a max of 32 characters so it may be insufficient.
    # Crashlogger prefixes such names with "app:" for our benefit.
    if dso == "app" or dso.startswith("app:"):
        return find_file_in_build_dir(exe_name, build_dirs)
    # First, try an exact match for the filename
    found_path = find_file_in_build_dir(dso, build_dirs)
    if found_path == "":
        # If that fails, and this file doesn't end with .so, try the executable
        # name
        if not dso.endswith(".so"):
            found_path = find_file_in_build_dir(exe_name, build_dirs)
    if found_path == "":
        # If that still fails and this looks like an absolute path, try the
        # last path component
        if dso.startswith("/"):
            short_name = dso[dso.rfind("/"):]
            found_path = find_file_in_build_dir(short_name, build_dirs)
    if found_path != "":
        name_to_full_path[dso] = found_path
    return found_path


def tool_path(arch, tool):
    if sys.platform.startswith("linux"):
        platform = "Linux"
    elif sys.platform.startswith("darwin"):
        platform = "Darwin"
    else:
        raise Exception("Unsupported platform!")
    return ("%s/%s-elf-6.2.0-%s-x86_64/bin/%s-elf-%s" %
            (PREBUILTS_BASE_DIR, arch, platform, arch, tool))


def run_tool(arch, tool, *args):
    cmd = [tool_path(arch, tool)] + list(args)
    try:
        output = subprocess.check_output(cmd)
    except Exception as e:
        print "Calling %s failed: command %s error %s" % (tool, cmd, e)
        return False
    return output


def main():
    parser = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--build-dir", "-b", nargs="*",
                        help="List of additional build directories to search")
    parser.add_argument("--disassemble", "-d", action="store_true",
                        help="Show disassembly of each function")
    parser.add_argument("app", nargs="?", help="Name of primary application")
    args = parser.parse_args()

    magenta_build_dir = os.path.join(
        os.path.dirname(SCRIPT_DIR), "build-magenta-pc-x86-64")
    build_dirs = [magenta_build_dir]
    if args.build_dir:
        build_dirs += args.build_dir
    else:
        fuchsia_build_dir = os.path.abspath(os.path.join(
            os.path.dirname(SCRIPT_DIR), os.pardir, "out", "debug-x86-64"))
        build_dirs.append(fuchsia_build_dir)

    # Either nothing, or something like "[00007.268] 00304.00325> "
    full_prefix = "^(|\[\d+\.\d+\] \d+\.\d+> )"
    btre = re.compile(full_prefix + "bt#(\d+):")
    bt_with_offsetre = re.compile(full_prefix +
        "bt#(\d+): pc 0x[0-9a-z]+ sp 0x[0-9a-z]+ \((\S+),(0x[0-9a-z]+)\)$")
    bt_end = re.compile(full_prefix + "bt#(\d+): end")
    arch_re = re.compile(full_prefix + "arch: ([\\S]+)$")
    arch = "x86_64"
    build_id_re = re.compile(full_prefix +
        "dso: id=([0-9a-z]+) base=(0x[0-9a-f]+) name=([\\S]+)$")
    disasm_re = re.compile("^ *(0x[0-9a-f]+)( .+)$")
    name_to_buildid = {}
    processed_lines = []
    while True:
        line = sys.stdin.readline()
        if not sys.stdin.isatty():
            sys.stdout.write(line)
        end_of_file = (line == '')
        # Strip any trailing carriage return character ("\r"), since
        # these appear in the serial console output from QEMU.
        line = line.rstrip()
        if bt_end.match(line):
            if len(processed_lines) != 0:
                print "start of symbolized stack:"
                sys.stdout.writelines(processed_lines)
                print "end of symbolized stack"
                processed_lines = []
                name_to_buildid = {}
            if end_of_file:
                break
        m = arch_re.match(line)
        if m:
            arch = m.group(2)
            continue
        m = build_id_re.match(line)
        if m:
            buildid = m.group(2)
            bias = int(m.group(3), 16)
            name = m.group(4)
            name_to_buildid[name] = buildid
            continue
        m = btre.match(line)
        if m:
            frame_num = m.group(2)
            m = bt_with_offsetre.match(line)
            if m:
                dso = m.group(3)
                off = m.group(4)
                dso_full_path = find_dso_full_path(
                    dso, args.app, name_to_buildid, build_dirs)
                if dso_full_path:
                    addr2line_output = run_tool(arch, "addr2line", "-Cipfe",
                                                dso_full_path, off)
                    if addr2line_output:
                        processed_lines.append(
                            "#%s: %s" % (frame_num, addr2line_output))
                    if args.disassemble:
                        pc = int(off, 16)
                        disassembly = run_tool(
                            arch, "gdb", "--nx", "--batch", "-ex",
                            "disassemble %#x" % pc, dso_full_path)
                        if disassembly:
                            for line in disassembly.splitlines():
                                m = disasm_re.match(line)
                                if m:
                                    timestamp, addr, rest = m.groups()
                                    addr = int(addr, 16)
                                    if addr == pc:
                                        prefix = "=> "
                                    else:
                                        prefix = "   "
                                        line = "%s%#.16x%s" % (prefix, bias + addr, rest)
                                    processed_lines.append(line + "\n")
                    continue
            processed_lines.append("#%s: (unknown)\n" % frame_num)

if __name__ == '__main__':
    sys.exit(main())
