Improve android stack parser

There was several issues with the Android stack parser that prevented it
to work correctly in a number of cases. The two cases fixed here:
 - When a mojo app is retrieved with arguments
(dart_content_handler.mojo?strict=true), we want to ignore ?strict=true
when looking for a match on the local filesystem.
 - There were cases when paths were not normalized correctly (for example,
double-slashes), preventing the libraries to match.

This fixes domokit/devtools#23

R=ppi@chromium.org, ppi

Review URL: https://codereview.chromium.org/1306603002 .

Cr-Mirrored-From: https://github.com/domokit/mojo
Cr-Mirrored-Commit: 763f44f16c1f564be73b9c6dbaef3edba542b157
diff --git a/android_stack_parser/stack b/android_stack_parser/stack
index 97913ab..f75d097 100755
--- a/android_stack_parser/stack
+++ b/android_stack_parser/stack
@@ -17,11 +17,11 @@
 """stack symbolizes native crash dumps."""
 
 import getopt
-import glob
 import os
-import re
+import os.path
 import sys
 
+import stack_utils
 import stack_core
 import subprocess
 import symbol
@@ -71,102 +71,6 @@
   # pylint: enable-msg=C6310
   sys.exit(1)
 
-def UnzipSymbols(symbolfile, symdir=None):
-  """Unzips a file to _DEFAULT_SYMROOT and returns the unzipped location.
-
-  Args:
-    symbolfile: The .zip file to unzip
-    symdir: Optional temporary directory to use for extraction
-
-  Returns:
-    A tuple containing (the directory into which the zip file was unzipped,
-    the path to the "symbols" directory in the unzipped file).  To clean
-    up, the caller can delete the first element of the tuple.
-
-  Raises:
-    SymbolDownloadException: When the unzip fails.
-  """
-  if not symdir:
-    symdir = "%s/%s" % (_DEFAULT_SYMROOT, hash(symbolfile))
-  if not os.path.exists(symdir):
-    os.makedirs(symdir)
-
-  print "extracting %s..." % symbolfile
-  saveddir = os.getcwd()
-  os.chdir(symdir)
-  try:
-    unzipcode = subprocess.call(["unzip", "-qq", "-o", symbolfile])
-    if unzipcode > 0:
-      os.remove(symbolfile)
-      raise SymbolDownloadException("failed to extract symbol files (%s)."
-                                    % symbolfile)
-  finally:
-    os.chdir(saveddir)
-
-  android_symbols = glob.glob("%s/out/target/product/*/symbols" % symdir)
-  if android_symbols:
-    return (symdir, android_symbols[0])
-  else:
-    # This is a zip of Chrome symbols, so symbol.CHROME_SYMBOLS_DIR needs to be
-    # updated to point here.
-    symbol.CHROME_SYMBOLS_DIR = symdir
-    return (symdir, symdir)
-
-
-def GetBasenameFromMojoApp(url):
-  """Used by GetSymbolMapping() to extract the basename from the location the
-  mojo app was downloaded from. The location is a URL, e.g.
-  http://foo/bar/x.so."""
-  index = url.rfind('/')
-  return url[(index + 1):] if index != -1 else url
-
-
-def GetSymboledNameForMojoApp(path):
-  """Used by GetSymbolMapping to get the non-stripped library name for an
-  installed mojo app."""
-  # e.g. tracing.mojo -> libtracing_library.so
-  name, ext = os.path.splitext(path)
-  if ext != '.mojo':
-    return path
-  return 'lib%s_library.so' % name
-
-
-def GetSymbolMapping(lines):
-  """Returns a mapping (dictionary) from download file to .so."""
-  regex = re.compile('Caching mojo app (\S+) at (\S+)')
-  mappings = {}
-  for line in lines:
-    result = regex.search(line)
-    if result:
-      url = GetBasenameFromMojoApp(result.group(1))
-      mappings[result.group(2)] = GetSymboledNameForMojoApp(url)
-  return mappings
-
-
-def _LowestAncestorContainingRelpath(dir_path, relpath):
-  """Returns the lowest ancestor dir of |dir_path| that contains |relpath|.
-  """
-  cur_dir_path = os.path.abspath(dir_path)
-  while True:
-    if os.path.exists(os.path.join(cur_dir_path, relpath)):
-      return cur_dir_path
-
-    next_dir_path = os.path.dirname(cur_dir_path)
-    if next_dir_path != cur_dir_path:
-      cur_dir_path = next_dir_path
-    else:
-      return None
-
-
-def _GuessDir(relpath):
-  """Returns absolute path to location |relpath| in the lowest ancestor of this
-  file that contains it."""
-  lowest_ancestor = _LowestAncestorContainingRelpath(
-      os.path.dirname(__file__), relpath)
-  if not lowest_ancestor:
-    return None
-  return os.path.join(lowest_ancestor, relpath)
-
 
 
 def main():
@@ -204,7 +108,7 @@
       more_info = False
 
   if not symbol.BUILD_DIR:
-    guess = _GuessDir(_DEFAULT_BUILD_DIR)
+    guess = stack_utils.GuessDir(_DEFAULT_BUILD_DIR)
     if not guess:
       print "Couldn't find the build directory, please pass --build-dir."
       return 1
@@ -212,7 +116,7 @@
     symbol.BUILD_DIR = guess
 
   if not symbol.NDK_DIR:
-    guess = _GuessDir(_DEFAULT_NDK_DIR)
+    guess = stack_utils.GuessDir(_DEFAULT_NDK_DIR)
     if not guess:
       print "Couldn't find the Android NDK, please pass --ndk-dir."
       return 1
@@ -235,13 +139,13 @@
 
   rootdir = None
   if zip_arg:
-    rootdir, symbol.SYMBOLS_DIR = UnzipSymbols(zip_arg)
+    rootdir, symbol.SYMBOLS_DIR = stack_utils.UnzipSymbols(zip_arg)
 
   if symbol.SYMBOLS_DIR:
     print "Reading Android symbols from", symbol.SYMBOLS_DIR
 
   print "Reading Mojo symbols from", symbol.BUILD_DIR
-  stack_core.ConvertTrace(lines, more_info, GetSymbolMapping(lines))
+  stack_core.ConvertTrace(lines, more_info, stack_utils.GetSymbolMapping(lines))
 
   if rootdir:
     # be a good citizen and clean up...os.rmdir and os.removedirs() don't work
diff --git a/android_stack_parser/stack_utils.py b/android_stack_parser/stack_utils.py
new file mode 100644
index 0000000..f6efb86
--- /dev/null
+++ b/android_stack_parser/stack_utils.py
@@ -0,0 +1,108 @@
+# Copyright 2015 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Utility functions for the stack tool."""
+
+import glob
+import os
+import os.path
+import re
+
+
+def UnzipSymbols(symbolfile, symdir=None):
+  """Unzips a file to _DEFAULT_SYMROOT and returns the unzipped location.
+
+  Args:
+    symbolfile: The .zip file to unzip
+    symdir: Optional temporary directory to use for extraction
+
+  Returns:
+    A tuple containing (the directory into which the zip file was unzipped,
+    the path to the "symbols" directory in the unzipped file).  To clean
+    up, the caller can delete the first element of the tuple.
+
+  Raises:
+    SymbolDownloadException: When the unzip fails.
+  """
+  if not symdir:
+    symdir = "%s/%s" % (_DEFAULT_SYMROOT, hash(symbolfile))
+  if not os.path.exists(symdir):
+    os.makedirs(symdir)
+
+  print "extracting %s..." % symbolfile
+  saveddir = os.getcwd()
+  os.chdir(symdir)
+  try:
+    unzipcode = subprocess.call(["unzip", "-qq", "-o", symbolfile])
+    if unzipcode > 0:
+      os.remove(symbolfile)
+      raise SymbolDownloadException("failed to extract symbol files (%s)."
+                                    % symbolfile)
+  finally:
+    os.chdir(saveddir)
+
+  android_symbols = glob.glob("%s/out/target/product/*/symbols" % symdir)
+  if android_symbols:
+    return (symdir, android_symbols[0])
+  else:
+    # This is a zip of Chrome symbols, so symbol.CHROME_SYMBOLS_DIR needs to be
+    # updated to point here.
+    symbol.CHROME_SYMBOLS_DIR = symdir
+    return (symdir, symdir)
+
+
+def GetBasenameFromMojoApp(url):
+  """Used by GetSymbolMapping() to extract the basename from the location the
+  mojo app was downloaded from. The location is a URL, e.g.
+  http://foo/bar/x.so."""
+  index = url.rfind('/')
+  return url[(index + 1):] if index != -1 else url
+
+
+def GetSymboledNameForMojoApp(path):
+  """Used by GetSymbolMapping to get the non-stripped library name for an
+  installed mojo app."""
+  # e.g. tracing.mojo -> libtracing_library.so
+  name, ext = os.path.splitext(path)
+  if ext != '.mojo':
+    return path
+  return 'lib%s_library.so' % name
+
+
+def GetSymbolMapping(lines):
+  """Returns a mapping (dictionary) from download file to .so."""
+  regex = re.compile('Caching mojo app (\S+?)(?:\?\S+)? at (\S+)')
+  mappings = {}
+  for line in lines:
+    result = regex.search(line)
+    if result:
+      url = GetBasenameFromMojoApp(result.group(1))
+      mappings[os.path.normpath(result.group(2))] = GetSymboledNameForMojoApp(
+          url)
+  return mappings
+
+
+def _LowestAncestorContainingRelpath(dir_path, relpath):
+  """Returns the lowest ancestor dir of |dir_path| that contains |relpath|.
+  """
+  cur_dir_path = os.path.abspath(dir_path)
+  while True:
+    if os.path.exists(os.path.join(cur_dir_path, relpath)):
+      return cur_dir_path
+
+    next_dir_path = os.path.dirname(cur_dir_path)
+    if next_dir_path != cur_dir_path:
+      cur_dir_path = next_dir_path
+    else:
+      return None
+
+
+def GuessDir(relpath):
+  """Returns absolute path to location |relpath| in the lowest ancestor of this
+  file that contains it."""
+  lowest_ancestor = _LowestAncestorContainingRelpath(
+      os.path.dirname(__file__), relpath)
+  if not lowest_ancestor:
+    return None
+  return os.path.join(lowest_ancestor, relpath)
diff --git a/android_stack_parser/stack_utils_unittest.py b/android_stack_parser/stack_utils_unittest.py
new file mode 100644
index 0000000..6329aa6
--- /dev/null
+++ b/android_stack_parser/stack_utils_unittest.py
@@ -0,0 +1,70 @@
+# Copyright 2015 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Tests for the native crash dump symbolizer utility functions."""
+
+import unittest
+import stack_utils
+
+
+class StackUtilsTest(unittest.TestCase):
+  """Tests the native crash dump symbolizer utility functions."""
+
+  def test_GetSymbolMapping_no_match(self):
+    """Verifies that, if no mapping is on the input, no mapping is on the
+    output.
+    """
+    lines = ["This is a test case\n", "Caching all mojo apps", ""]
+    self.assertDictEqual({}, stack_utils.GetSymbolMapping(lines))
+
+  def test_GetSymbolMapping_simple_match(self):
+    """Verifies a simple symbol mapping."""
+    lines = ["This is a test case\n", "Caching all mojo apps",
+        "I/mojo(2): [INFO:somefile.cc(85)] Caching mojo app "
+        "https://apps.mojo/myapp.mojo at /path/to/myapp.mojo/.lM03ws"]
+    golden_dict = {
+        "/path/to/myapp.mojo/.lM03ws": "libmyapp_library.so"
+    }
+    actual_dict = stack_utils.GetSymbolMapping(lines)
+    self.assertDictEqual(golden_dict, actual_dict)
+
+  def test_GetSymbolMapping_multiple_match(self):
+    """Verifies mapping of multiple symbol files."""
+    lines = ["This is a test case\n", "Caching all mojo apps",
+        "I/mojo(2): [INFO:somefile.cc(85)] Caching mojo app "
+        "https://apps.mojo/myapp.mojo at /path/to/myapp.mojo/.lM03ws",
+        "I/mojo(2): [INFO:somefile.cc(85)] Caching mojo app "
+        "https://apps.mojo/otherapp.mojo at /path/to/otherapp.mojo/.kW07s"]
+    golden_dict = {
+        "/path/to/myapp.mojo/.lM03ws": "libmyapp_library.so",
+        "/path/to/otherapp.mojo/.kW07s": "libotherapp_library.so"
+    }
+    actual_dict = stack_utils.GetSymbolMapping(lines)
+    self.assertDictEqual(golden_dict, actual_dict)
+
+  def test_GetSymbolMapping_parameter_match(self):
+    """Verifies parameters are stripped from mappings."""
+    lines = ["This is a test case\n", "Caching all mojo apps",
+        "I/mojo(2): [INFO:somefile.cc(85)] Caching mojo app "
+        "https://apps.mojo/myapp.mojo?q=hello at /path/to/myapp.mojo/.lM03ws"]
+    golden_dict = {
+        "/path/to/myapp.mojo/.lM03ws": "libmyapp_library.so"
+    }
+    actual_dict = stack_utils.GetSymbolMapping(lines)
+    self.assertDictEqual(golden_dict, actual_dict)
+
+  def test_GetSymbolMapping_normalize(self):
+    """Verifies paths are normalized in mappings."""
+    lines = ["This is a test case\n", "Caching all mojo apps",
+        "I/mojo(2): [INFO:somefile.cc(85)] Caching mojo app "
+        "https://apps.mojo/myapp.mojo at /path/to/.//myapp.mojo/.lM03ws"]
+    golden_dict = {
+        "/path/to/myapp.mojo/.lM03ws": "libmyapp_library.so"
+    }
+    actual_dict = stack_utils.GetSymbolMapping(lines)
+    self.assertDictEqual(golden_dict, actual_dict)
+
+
+if __name__ == "__main__":
+  unittest.main()