Changes analyzer to search for targets

Here's a list of the changes:
. I'm changing input file to be a JSON file. This allows for passing
  in more information, such as the set of targets to search for.
. Changing output format to be JSON as well. This allows for more
  structure in outputting other random data. I'm a bit iffy on whether
  this is good, but it's requested by the recipes side of things which
  uses analyzer.
. You can now specify a set of targets to search for in addition to
  files. This is intended for things like 'unittests' so that only the
  set of targets that are effected by the change get run.

To enable the new functionality you have to pass in a new switch. Once
I integrate these changes into the rest of the stack. I'll remove the
old flow.

BUG=109173
TEST=covered by tests
R=mark@chromium.org

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

git-svn-id: http://gyp.googlecode.com/svn/trunk@1956 78cadc50-ecff-11dd-a971-7dbc132099af
diff --git a/pylib/gyp/generator/analyzer.py b/pylib/gyp/generator/analyzer.py
index 8b83f80..6f3b610 100644
--- a/pylib/gyp/generator/analyzer.py
+++ b/pylib/gyp/generator/analyzer.py
@@ -4,18 +4,44 @@
 
 """
 This script is intended for use as a GYP_GENERATOR. It takes as input (by way of
-the generator flag file_path) the list of relative file paths to consider. If
-any target has at least one of the paths as a source (or input to an action or
-rule) then 'Found dependency' is output, otherwise 'No dependencies' is output.
+the generator flag config_path) the path of a json file that dictates the files
+and targets to search for. The following keys are supported:
+files: list of paths (relative) of the files to search for.
+targets: list of targets to search for. The target names are unqualified.
+
+The following (as JSON) is output:
+error: only supplied if there is an error.
+targets: the set of targets passed in via targets that either directly or
+  indirectly depend upon the set of paths supplied in files.
+status: indicates if any of the supplied files matched at least one target.
 """
 
 import gyp.common
 import gyp.ninja_syntax as ninja_syntax
+import json
 import os
 import posixpath
+import sys
 
 debug = False
 
+found_dependency_string = 'Found dependency'
+no_dependency_string = 'No dependencies'
+
+# MatchStatus is used indicate if and how a target depends upon the supplied
+# sources.
+# The target's sources contain one of the supplied paths.
+MATCH_STATUS_MATCHES = 1
+# The target has a dependency on another target that contains one of the
+# supplied paths.
+MATCH_STATUS_MATCHES_BY_DEPENDENCY = 2
+# The target's sources weren't in the supplied paths and none of the target's
+# dependencies depend upon a target that matched.
+MATCH_STATUS_DOESNT_MATCH = 3
+# The target doesn't contain the source, but the dependent targets have not yet
+# been visited to determine a more specific status yet.
+MATCH_STATUS_TBD = 4
+
 generator_supports_multiple_toolsets = True
 
 generator_wants_static_library_dependencies_adjusted = False
@@ -126,21 +152,79 @@
 
 class Target(object):
   """Holds information about a particular target:
-  sources: set of source files defined by this target. This includes inputs to
-           actions and rules.
-  deps: list of direct dependencies."""
+  deps: set of the names of direct dependent targets.
+  match_staus: one of the MatchStatus values"""
   def __init__(self):
-    self.sources = []
-    self.deps = []
+    self.deps = set()
+    self.match_status = MATCH_STATUS_TBD
 
-def __GenerateTargets(target_list, target_dicts, toplevel_dir):
+class Config(object):
+  """Details what we're looking for
+  look_for_dependency_only: if true only search for a target listing any of
+                            the files in files.
+  files: set of files to search for
+  targets: see file description for details"""
+  def __init__(self):
+    self.look_for_dependency_only = True
+    self.files = []
+    self.targets = []
+
+  def Init(self, params):
+    """Initializes Config. This is a separate method as it may raise an
+    exception if there is a parse error."""
+    generator_flags = params.get('generator_flags', {})
+    # TODO(sky): nuke file_path and look_for_dependency_only once migrate
+    # recipes.
+    file_path = generator_flags.get('file_path', None)
+    if file_path:
+      self._InitFromFilePath(file_path)
+      return
+
+    # If |file_path| wasn't specified then we look for config_path.
+    # TODO(sky): always look for config_path once migrated recipes.
+    config_path = generator_flags.get('config_path', None)
+    if not config_path:
+      return
+    self.look_for_dependency_only = False
+    try:
+      f = open(config_path, 'r')
+      config = json.load(f)
+      f.close()
+    except IOError:
+      raise Exception('Unable to open file ' + config_path)
+    except ValueError as e:
+      raise Exception('Unable to parse config file ' + config_path + str(e))
+    if not isinstance(config, dict):
+      raise Exception('config_path must be a JSON file containing a dictionary')
+    self.files = config.get('files', [])
+    # Coalesce duplicates
+    self.targets = list(set(config.get('targets', [])))
+
+  def _InitFromFilePath(self, file_path):
+    try:
+      f = open(file_path, 'r')
+      for file_name in f:
+        if file_name.endswith('\n'):
+          file_name = file_name[0:len(file_name) - 1]
+          if len(file_name):
+            self.files.append(file_name)
+      f.close()
+    except IOError:
+      raise Exception('Unable to open file', file_path)
+
+def __GenerateTargets(target_list, target_dicts, toplevel_dir, files):
   """Generates a dictionary with the key the name of a target and the value a
-  Target. |toplevel_dir| is the root of the source tree."""
+  Target. |toplevel_dir| is the root of the source tree. If the sources of
+  a target match that of |files|, then |target.matched| is set to True.
+  This returns a tuple of the dictionary and whether at least one target's
+  sources listed one of the paths in |files|."""
   targets = {}
 
   # Queue of targets to visit.
   targets_to_visit = target_list[:]
 
+  matched = False
+
   while len(targets_to_visit) > 0:
     target_name = targets_to_visit.pop()
     if target_name in targets:
@@ -148,35 +232,66 @@
 
     target = Target()
     targets[target_name] = target
-    target.sources.extend(__ExtractSources(target_name,
-                                           target_dicts[target_name],
-                                           toplevel_dir))
+    sources = __ExtractSources(target_name, target_dicts[target_name],
+                               toplevel_dir)
+    for source in sources:
+      if source in files:
+        target.match_status = MATCH_STATUS_MATCHES
+        matched = True
+        break
 
     for dep in target_dicts[target_name].get('dependencies', []):
-      targets[target_name].deps.append(dep)
+      targets[target_name].deps.add(dep)
       targets_to_visit.append(dep)
 
-  return targets
+  return targets, matched
 
-def __GetFiles(params):
-  """Returns the list of files to analyze, or None if none specified."""
-  generator_flags = params.get('generator_flags', {})
-  file_path = generator_flags.get('file_path', None)
-  if not file_path:
-    return None
-  try:
-    f = open(file_path, 'r')
-    result = []
-    for file_name in f:
-      if file_name.endswith('\n'):
-        file_name = file_name[0:len(file_name) - 1]
-      if len(file_name):
-        result.append(file_name)
-    f.close()
+def _GetUnqualifiedToQualifiedMapping(all_targets, to_find):
+  """Returns a mapping (dictionary) from unqualified name to qualified name for
+  all the targets in |to_find|."""
+  result = {}
+  if not to_find:
     return result
-  except IOError:
-    print 'Unable to open file', file_path
-  return None
+  to_find = set(to_find)
+  for target_name in all_targets.keys():
+    extracted = gyp.common.ParseQualifiedTarget(target_name)
+    if len(extracted) > 1 and extracted[1] in to_find:
+      to_find.remove(extracted[1])
+      result[extracted[1]] = target_name
+      if not to_find:
+        return result
+  return result
+
+def _DoesTargetDependOn(target, all_targets):
+  """Returns true if |target| or any of its dependencies matches the supplied
+  set of paths. This updates |matches| of the Targets as it recurses.
+  target: the Target to look for.
+  all_targets: mapping from target name to Target.
+  matching_targets: set of targets looking for."""
+  if target.match_status == MATCH_STATUS_DOESNT_MATCH:
+    return False
+  if target.match_status == MATCH_STATUS_MATCHES or \
+      target.match_status == MATCH_STATUS_MATCHES_BY_DEPENDENCY:
+    return True
+  for dep_name in target.deps:
+    dep_target = all_targets[dep_name]
+    if _DoesTargetDependOn(dep_target, all_targets):
+      dep_target.match_status = MATCH_STATUS_MATCHES_BY_DEPENDENCY
+      return True
+    dep_target.match_status = MATCH_STATUS_DOESNT_MATCH
+  return False
+
+def _GetTargetsDependingOn(all_targets, possible_targets):
+  """Returns the list of targets in |possible_targets| that depend (either
+  directly on indirectly) on the matched files.
+  all_targets: mapping from target name to Target.
+  possible_targets: targets to search from."""
+  found = []
+  for target in possible_targets:
+    if _DoesTargetDependOn(all_targets[target], all_targets):
+      # possible_targets was initially unqualified, keep it unqualified.
+      found.append(gyp.common.ParseQualifiedTarget(target)[1])
+  return found
 
 def CalculateVariables(default_variables, params):
   """Calculate additional variables for use in the build (called by gyp)."""
@@ -202,26 +317,48 @@
 
 def GenerateOutput(target_list, target_dicts, data, params):
   """Called by gyp as the final stage. Outputs results."""
-  files = __GetFiles(params)
-  if not files:
-    print 'Must specify files to analyze via file_path generator flag'
-    return
+  config = Config()
+  try:
+    config.Init(params)
+    if not config.files:
+      if config.look_for_dependency_only:
+        print 'Must specify files to analyze via file_path generator flag'
+        return
+      raise Exception('Must specify files to analyze via config_path generator '
+                      'flag')
 
-  toplevel_dir = os.path.abspath(params['options'].toplevel_dir)
-  if os.sep == '\\' and os.altsep == '/':
-    toplevel_dir = toplevel_dir.replace('\\', '/')
-  if debug:
-    print 'toplevel_dir', toplevel_dir
-  targets = __GenerateTargets(target_list, target_dicts, toplevel_dir)
+    toplevel_dir = os.path.abspath(params['options'].toplevel_dir)
+    if os.sep == '\\' and os.altsep == '/':
+      toplevel_dir = toplevel_dir.replace('\\', '/')
+    if debug:
+      print 'toplevel_dir', toplevel_dir
 
-  files_set = frozenset(files)
-  found_in_all_sources = 0
-  for target_name, target in targets.iteritems():
-    sources = files_set.intersection(target.sources)
-    if len(sources):
-      print 'Found dependency'
-      if debug:
-        print 'Found dependency in', target_name, target.sources
+    all_targets, matched = __GenerateTargets(target_list, target_dicts,
+                                             toplevel_dir,
+                                             frozenset(config.files))
+
+    # Set of targets that refer to one of the files.
+    if config.look_for_dependency_only:
+      print found_dependency_string if matched else no_dependency_string
       return
 
-  print 'No dependencies'
+    if matched:
+      unqualified_mapping = _GetUnqualifiedToQualifiedMapping(
+          all_targets, config.targets)
+      if len(unqualified_mapping) != len(config.targets):
+        not_found = []
+        for target in config.targets:
+          if not target in unqualified_mapping:
+            not_found.append(target)
+        raise Exception('Unable to find all targets: ' + str(not_found))
+      qualified_targets = [unqualified_mapping[x] for x in config.targets]
+      output_targets = _GetTargetsDependingOn(all_targets, qualified_targets)
+    else:
+      output_targets = []
+
+    print json.dumps(
+      {'targets': output_targets,
+       'status': found_dependency_string if matched else no_dependency_string })
+
+  except Exception as e:
+    print json.dumps({'error': str(e)})
diff --git a/test/analyzer/subdir/subdir.gyp b/test/analyzer/subdir/subdir.gyp
index cd0f013..bfa2df4 100644
--- a/test/analyzer/subdir/subdir.gyp
+++ b/test/analyzer/subdir/subdir.gyp
@@ -15,5 +15,22 @@
         '<(trailing_dir_path)/parent_source.c',
       ],
     },
+    {
+      'target_name': 'subdir2a',
+      'type': 'static_library',
+      'sources': [
+        'subdir2_source.c',
+      ],
+      'dependencies': [
+        'subdir2b',
+      ],
+    },
+    {
+      'target_name': 'subdir2b',
+      'type': 'static_library',
+      'sources': [
+        'subdir2b_source.c',
+      ],
+    },
   ],
 }
diff --git a/test/analyzer/test.gyp b/test/analyzer/test.gyp
index 482ede3..afc312b 100644
--- a/test/analyzer/test.gyp
+++ b/test/analyzer/test.gyp
@@ -53,5 +53,31 @@
         },
       ],
     },
+    {
+      'target_name': 'exe2',
+      'type': 'executable',
+      'sources': [
+        'exe2.c',
+      ],
+    },
+    {
+      'target_name': 'exe3',
+      'type': 'executable',
+      'dependencies': [
+        'subdir/subdir.gyp:foo',
+        'subdir/subdir.gyp:subdir2a',
+      ],
+      'sources': [
+        'exe3.c',
+      ],
+    },
+    {
+      'target_name': 'all',
+      'type': 'executable',
+      'dependencies': [
+        'exe',
+        'exe3',
+      ],
+    },
   ],
 }