Copies the latest setup_ios_gn.py from Chromium.

Also includes the latest convert_gn_xcodeproj.py.

BUG=None

Change-Id: I6a7f0c7e94575e3b2a6880d616e1a28c65e3e9e5
Reviewed-on: https://chromium-review.googlesource.com/c/crashpad/crashpad/+/2276602
Reviewed-by: Mark Mentovai <mark@chromium.org>
Commit-Queue: Rohit Rao <rohitrao@chromium.org>
GitOrigin-RevId: 17b08b5aab64a1bac9d78e3a8043c213ae2656c4
diff --git a/build/ios/convert_gn_xcodeproj.py b/build/ios/convert_gn_xcodeproj.py
index c00d331..3bbbb4e 100755
--- a/build/ios/convert_gn_xcodeproj.py
+++ b/build/ios/convert_gn_xcodeproj.py
@@ -29,8 +29,6 @@
 import json
 import hashlib
 import os
-import plistlib
-import random
 import shutil
 import subprocess
 import sys
@@ -47,7 +45,8 @@
         while True:
             self.counter += 1
             str_id = "%s %s %d" % (parent_name, obj['isa'], self.counter)
-            new_id = hashlib.sha1(str_id).hexdigest()[:24].upper()
+            new_id = hashlib.sha1(
+                str_id.encode("utf-8")).hexdigest()[:24].upper()
 
             # Make sure ID is unique. It's possible there could be an id
             # conflict since this is run after GN runs.
@@ -56,46 +55,65 @@
                 return new_id
 
 
+def check_output(command):
+    """Wrapper around subprocess.check_output that decode output as utf-8."""
+    return subprocess.check_output(command).decode('utf-8')
+
+
 def CopyFileIfChanged(source_path, target_path):
-    """Copy |source_path| to |target_path| is different."""
+    """Copy |source_path| to |target_path| if different."""
     target_dir = os.path.dirname(target_path)
     if not os.path.isdir(target_dir):
         os.makedirs(target_dir)
-    if (not os.path.exists(target_path) or
-            not filecmp.cmp(source_path, target_path)):
+    if not os.path.exists(target_path) or \
+        not filecmp.cmp(source_path, target_path):
         shutil.copyfile(source_path, target_path)
 
 
-def LoadXcodeProjectAsJSON(path):
+def CopyTreeIfChanged(source, target):
+    """Copy |source| to |target| recursively; files are copied iff changed."""
+    if os.path.isfile(source):
+        return CopyFileIfChanged(source, target)
+    if not os.path.isdir(target):
+        os.makedirs(target)
+    for name in os.listdir(source):
+        CopyTreeIfChanged(os.path.join(source, name),
+                          os.path.join(target, name))
+
+
+def LoadXcodeProjectAsJSON(project_dir):
     """Return Xcode project at |path| as a JSON string."""
-    return subprocess.check_output(
-        ['plutil', '-convert', 'json', '-o', '-', path])
+    return check_output([
+        'plutil', '-convert', 'json', '-o', '-',
+        os.path.join(project_dir, 'project.pbxproj')
+    ])
 
 
 def WriteXcodeProject(output_path, json_string):
     """Save Xcode project to |output_path| as XML."""
     with tempfile.NamedTemporaryFile() as temp_file:
-        temp_file.write(json_string)
+        temp_file.write(json_string.encode("utf-8"))
         temp_file.flush()
         subprocess.check_call(['plutil', '-convert', 'xml1', temp_file.name])
-        CopyFileIfChanged(temp_file.name, output_path)
+        CopyFileIfChanged(temp_file.name,
+                          os.path.join(output_path, 'project.pbxproj'))
 
 
-def UpdateProductsProject(file_input, file_output, configurations, root_dir):
-    """Update Xcode project to support multiple configurations.
+def UpdateXcodeProject(project_dir, configurations, root_dir):
+    """Update inplace Xcode project to support multiple configurations.
 
     Args:
-        file_input: path to the input Xcode project
-        file_output: path to the output file
-        configurations: list of string corresponding to the configurations that
+      project_dir: path to the input Xcode project
+      configurations: list of string corresponding to the configurations that
         need to be supported by the tweaked Xcode projects, must contains at
         least one value.
+      root_dir: path to the root directory used to find markdown files
     """
-    json_data = json.loads(LoadXcodeProjectAsJSON(file_input))
+    json_data = json.loads(LoadXcodeProjectAsJSON(project_dir))
     project = XcodeProject(json_data['objects'])
 
     objects_to_remove = []
-    for value in project.objects.values():
+    for value in list(project.objects.values()):
         isa = value['isa']
 
         # Teach build shell script to look for the configuration and platform.
@@ -111,10 +129,8 @@
 
             build_config_template = project.objects[value['buildConfigurations']
                                                     [0]]
-            build_settings = build_config_template['buildSettings']
-            build_settings['CONFIGURATION_BUILD_DIR'] = (
-                '$(PROJECT_DIR)/../$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)')
-            build_settings['CODE_SIGN_IDENTITY'] = ''
+            build_config_template['buildSettings']['CONFIGURATION_BUILD_DIR'] =\
+                '$(PROJECT_DIR)/../$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)'
 
             value['buildConfigurations'] = []
             for configuration in configurations:
@@ -126,20 +142,96 @@
     for object_id in objects_to_remove:
         del project.objects[object_id]
 
-    AddMarkdownToProject(project, root_dir, json_data['rootObject'])
+    source = GetOrCreateRootGroup(project, json_data['rootObject'], 'Source')
+    AddMarkdownToProject(project, root_dir, source)
+    SortFileReferencesByName(project, source)
 
-    objects = collections.OrderedDict(sorted(project.objects.iteritems()))
-    WriteXcodeProject(file_output, json.dumps(json_data))
+    objects = collections.OrderedDict(sorted(project.objects.items()))
+    WriteXcodeProject(project_dir, json.dumps(json_data))
 
 
-def AddMarkdownToProject(project, root_dir, root_object):
+def CreateGroup(project, parent_group, group_name, path=None):
+    group_object = {
+        'children': [],
+        'isa': 'PBXGroup',
+        'name': group_name,
+        'sourceTree': '<group>',
+    }
+    if path is not None:
+        group_object['path'] = path
+    parent_group_name = parent_group.get('name', '')
+    group_object_key = project.AddObject(parent_group_name, group_object)
+    parent_group['children'].append(group_object_key)
+    return group_object
+
+
+def GetOrCreateRootGroup(project, root_object, group_name):
+    main_group = project.objects[project.objects[root_object]['mainGroup']]
+    for child_key in main_group['children']:
+        child = project.objects[child_key]
+        if child['name'] == group_name:
+            return child
+    return CreateGroup(project, main_group, group_name, path='../..')
+
+
+class ObjectKey(object):
+    """Wrapper around PBXFileReference and PBXGroup for sorting.
+
+    A PBXGroup represents a "directory" containing a list of files in an
+    Xcode project; it can contain references to a list of directories or
+    files.
+
+    A PBXFileReference represents a "file".
+
+    The type is stored in the object "isa" property as a string. Since we
+    want to sort all directories before all files, the < and > operators
+    are defined so that if "isa" is different, they are sorted in the
+    reverse of alphabetic ordering, otherwise the name (or path) property
+    is checked and compared in alphabetic order.
+    """
+
+    def __init__(self, obj):
+        self.isa = obj['isa']
+        if 'name' in obj:
+            self.name = obj['name']
+        else:
+            self.name = obj['path']
+
+    def __lt__(self, other):
+        if self.isa != other.isa:
+            return self.isa > other.isa
+        return self.name < other.name
+
+    def __gt__(self, other):
+        if self.isa != other.isa:
+            return self.isa < other.isa
+        return self.name > other.name
+
+    def __eq__(self, other):
+        return self.isa == other.isa and self.name == other.name
+
+
+def SortFileReferencesByName(project, group_object):
+    SortFileReferencesByNameWithSortKey(
+        project, group_object, lambda ref: ObjectKey(project.objects[ref]))
+
+
+def SortFileReferencesByNameWithSortKey(project, group_object, sort_key):
+    group_object['children'].sort(key=sort_key)
+    for key in group_object['children']:
+        child = project.objects[key]
+        if child['isa'] == 'PBXGroup':
+            SortFileReferencesByNameWithSortKey(project, child, sort_key)
+
+
+def AddMarkdownToProject(project, root_dir, group_object):
     list_files_cmd = ['git', '-C', root_dir, 'ls-files', '*.md']
-    paths = subprocess.check_output(list_files_cmd).splitlines()
+    paths = check_output(list_files_cmd).splitlines()
     ios_internal_dir = os.path.join(root_dir, 'ios_internal')
     if os.path.exists(ios_internal_dir):
         list_files_cmd = ['git', '-C', ios_internal_dir, 'ls-files', '*.md']
-        ios_paths = subprocess.check_output(list_files_cmd).splitlines()
-        paths.extend(["ios_internal/" + path for path in ios_paths])
+        ios_paths = check_output(list_files_cmd).splitlines()
+        paths.extend([os.path.join("ios_internal", path) for path in ios_paths])
     for path in paths:
         new_markdown_entry = {
             "fileEncoding": "4",
@@ -150,96 +242,71 @@
             "sourceTree": "<group>"
         }
         new_markdown_entry_id = project.AddObject('sources', new_markdown_entry)
-        folder = GetFolderForPath(project, root_object, os.path.dirname(path))
+        folder = GetFolderForPath(project, group_object, os.path.dirname(path))
         folder['children'].append(new_markdown_entry_id)
 
 
-def GetFolderForPath(project, rootObject, path):
+def GetFolderForPath(project, group_object, path):
     objects = project.objects
-    # 'Sources' is always the first child of
-    # project->rootObject->mainGroup->children.
-    root = objects[objects[objects[rootObject]['mainGroup']]['children'][0]]
     if not path:
-        return root
+        return group_object
     for folder in path.split('/'):
-        children = root['children']
+        children = group_object['children']
         new_root = None
         for child in children:
-            if (objects[child]['isa'] == 'PBXGroup' and
-                    objects[child]['name'] == folder):
+            if objects[child]['isa'] == 'PBXGroup' and \
+               objects[child]['name'] == folder:
                 new_root = objects[child]
                 break
         if not new_root:
             # If the folder isn't found we could just cram it into the leaf
             # existing folder, but that leads to folders with tons of README.md
             # inside.
-            new_group = {
-                "children": [],
-                "isa": "PBXGroup",
-                "name": folder,
-                "sourceTree": "<group>"
-            }
-            new_group_id = project.AddObject('sources', new_group)
-            children.append(new_group_id)
-            new_root = objects[new_group_id]
-        root = new_root
-    return root
-
-
-def DisableNewBuildSystem(output_dir):
-    """Disables the new build system due to crbug.com/852522 """
-    xcwspacesharedsettings = os.path.join(output_dir, 'all.xcworkspace',
-                                          'xcshareddata',
-                                          'WorkspaceSettings.xcsettings')
-    if os.path.isfile(xcwspacesharedsettings):
-        json_data = json.loads(LoadXcodeProjectAsJSON(xcwspacesharedsettings))
-    else:
-        json_data = {}
-    json_data['BuildSystemType'] = 'Original'
-    WriteXcodeProject(xcwspacesharedsettings, json.dumps(json_data))
+            new_root = CreateGroup(project, group_object, folder)
+        group_object = new_root
+    return group_object
 
 
 def ConvertGnXcodeProject(root_dir, input_dir, output_dir, configurations):
     '''Tweak the Xcode project generated by gn to support multiple
     configurations.
 
-    The Xcode projects generated by "gn gen --ide" only supports a single
-    platform and configuration (as the platform and configuration are set per
-    output directory). This method takes as input such projects and add support
-    for multiple configurations and platforms (to allow devs to select them in
-    Xcode).
+  The Xcode projects generated by "gn gen --ide" only supports a single
+  platform and configuration (as the platform and configuration are set
+  per output directory). This method takes as input such projects and
+  add support for multiple configurations and platforms (to allow devs
+  to select them in Xcode).
 
-    Args:
-        input_dir: directory containing the XCode projects created by "gn gen
-            --ide"
-        output_dir: directory where the tweaked Xcode projects will be saved
-        configurations: list of string corresponding to the configurations that
-            need to be supported by the tweaked Xcode projects, must contains at
-            least one value.
-    '''
-    # Update products project.
-    products = os.path.join('products.xcodeproj', 'project.pbxproj')
-    product_input = os.path.join(input_dir, products)
-    product_output = os.path.join(output_dir, products)
-    UpdateProductsProject(product_input, product_output, configurations,
-                          root_dir)
+  Args:
+    input_dir: directory containing the XCode projects created by "gn gen --ide"
+    output_dir: directory where the tweaked Xcode projects will be saved
+    configurations: list of string corresponding to the configurations that
+      need to be supported by the tweaked Xcode projects, must contains at
+      least one value.
+  '''
 
-    # Copy all workspace.
-    xcwspace = os.path.join('all.xcworkspace', 'contents.xcworkspacedata')
-    CopyFileIfChanged(os.path.join(input_dir, xcwspace),
-                      os.path.join(output_dir, xcwspace))
+    # Update the project (supports legacy name "products.xcodeproj" or the new
+    # project name "all.xcodeproj").
+    for project_name in ('all.xcodeproj', 'products.xcodeproj'):
+        if os.path.exists(os.path.join(input_dir, project_name)):
+            UpdateXcodeProject(os.path.join(input_dir, project_name),
+                               configurations, root_dir)
 
-    # TODO(crbug.com/852522): Disable new BuildSystemType.
-    DisableNewBuildSystem(output_dir)
+            CopyTreeIfChanged(os.path.join(input_dir, project_name),
+                              os.path.join(output_dir, project_name))
 
-    # TODO(crbug.com/679110): gn has been modified to remove 'sources.xcodeproj'
-    # and keep 'all.xcworkspace' and 'products.xcodeproj'. The following code is
-    # here to support both old and new projects setup and will be removed once
-    # gn has rolled past it.
-    sources = os.path.join('sources.xcodeproj', 'project.pbxproj')
-    if os.path.isfile(os.path.join(input_dir, sources)):
-        CopyFileIfChanged(os.path.join(input_dir, sources),
-                          os.path.join(output_dir, sources))
+        else:
+            shutil.rmtree(os.path.join(output_dir, project_name),
+                          ignore_errors=True)
+
+    # Copy all.xcworkspace if it exists (will be removed in a future gn version).
+    workspace_name = 'all.xcworkspace'
+    if os.path.exists(os.path.join(input_dir, workspace_name)):
+        CopyTreeIfChanged(os.path.join(input_dir, workspace_name),
+                          os.path.join(output_dir, workspace_name))
+    else:
+        shutil.rmtree(os.path.join(output_dir, workspace_name),
+                      ignore_errors=True)
 
 
 def Main(args):
@@ -264,8 +331,18 @@
         sys.stderr.write('Input directory does not exists.\n')
         return 1
 
-    required = set(['products.xcodeproj', 'all.xcworkspace'])
-    if not required.issubset(os.listdir(args.input)):
+    # Depending on the version of "gn", there should be either one project file
+    # named "all.xcodeproj" or a project file named "products.xcodeproj" and a
+    # workspace named "all.xcworkspace".
+    required_files_sets = [
+        set(("all.xcodeproj",)),
+        set(("products.xcodeproj", "all.xcworkspace")),
+    ]
+
+    for required_files in required_files_sets:
+        if required_files.issubset(os.listdir(args.input)):
+            break
+    else:
         sys.stderr.write(
             'Input directory does not contain all necessary Xcode projects.\n')
         return 1
diff --git a/build/ios/setup_ios_gn.py b/build/ios/setup_ios_gn.py
index 934b67c..5c70332 100755
--- a/build/ios/setup_ios_gn.py
+++ b/build/ios/setup_ios_gn.py
@@ -23,25 +23,29 @@
 import subprocess
 import sys
 import tempfile
-import ConfigParser
 
 try:
-    import cStringIO as StringIO
+    import configparser
 except ImportError:
-    import StringIO
+    import ConfigParser as configparser
+
+try:
+    import StringIO as io
+except ImportError:
+    import io
 
 SUPPORTED_TARGETS = ('iphoneos', 'iphonesimulator')
 SUPPORTED_CONFIGS = ('Debug', 'Release', 'Profile', 'Official', 'Coverage')
 
 
-class ConfigParserWithStringInterpolation(ConfigParser.SafeConfigParser):
+class ConfigParserWithStringInterpolation(configparser.SafeConfigParser):
     '''A .ini file parser that supports strings and environment variables.'''
 
     ENV_VAR_PATTERN = re.compile(r'\$([A-Za-z0-9_]+)')
 
     def values(self, section):
-        return map(lambda (k, v): self._UnquoteString(self._ExpandEnvVar(v)),
-                   ConfigParser.SafeConfigParser.items(self, section))
+        return map(lambda kv: self._UnquoteString(self._ExpandEnvVar(kv[1])),
+                   configparser.ConfigParser.items(self, section))
 
     def getstring(self, section, option):
         return self._UnquoteString(self._ExpandEnvVar(self.get(section,
@@ -94,7 +98,25 @@
         """
         args = []
 
+        # build/config/ios/ios_sdk.gni asserts that goma is not enabled when
+        # building Official, so ignore the value of goma.enabled when creating
+        # args.gn for Official.
+        if self._config != 'Official':
+            if self._settings.getboolean('goma', 'enabled'):
+                args.append(('use_goma', True))
+                goma_dir = self._settings.getstring('goma', 'install')
+                if goma_dir:
+                    args.append(
+                        ('goma_dir', '"%s"' % os.path.expanduser(goma_dir)))
+
         args.append(('is_debug', self._config in ('Debug', 'Coverage')))
+        args.append(('enable_dsyms', self._config in ('Profile', 'Official')))
+        args.append(('enable_stripping', 'enable_dsyms'))
+        args.append(('is_official_build', self._config == 'Official'))
+        args.append(('is_chrome_branded', 'is_official_build'))
+        args.append(('use_xcode_clang', 'false'))
+        args.append(('use_clang_coverage', self._config == 'Coverage'))
+        args.append(('is_component_build', False))
 
         if os.environ.get('FORCE_MAC_TOOLCHAIN', '0') == '1':
             args.append(('use_system_xcode', False))
@@ -116,7 +138,7 @@
         return args
 
     def Generate(self, gn_path, root_path, out_path):
-        buf = StringIO.StringIO()
+        buf = io.StringIO()
         self.WriteArgsGn(buf)
         WriteToFileIfChanged(os.path.join(out_path, 'args.gn'),
                              buf.getvalue(),
@@ -126,20 +148,20 @@
             self.GetGnCommand(gn_path, root_path, out_path, True))
 
     def CreateGnRules(self, gn_path, root_path, out_path):
-        buf = StringIO.StringIO()
+        buf = io.StringIO()
         self.WriteArgsGn(buf)
         WriteToFileIfChanged(os.path.join(out_path, 'args.gn'),
                              buf.getvalue(),
                              overwrite=True)
 
-        buf = StringIO.StringIO()
+        buf = io.StringIO()
         gn_command = self.GetGnCommand(gn_path, root_path, out_path, False)
         self.WriteBuildNinja(buf, gn_command)
         WriteToFileIfChanged(os.path.join(out_path, 'build.ninja'),
                              buf.getvalue(),
                              overwrite=False)
 
-        buf = StringIO.StringIO()
+        buf = io.StringIO()
         self.WriteBuildNinjaDeps(buf)
         WriteToFileIfChanged(os.path.join(out_path, 'build.ninja.d'),
                              buf.getvalue(),
@@ -196,16 +218,13 @@
         if generate_xcode_project:
             gn_command.append('--ide=xcode')
             gn_command.append('--root-target=gn_all')
-            if self._settings.getboolean('goma', 'enabled'):
-                ninja_jobs = self._settings.getint('xcode', 'jobs') or 200
-                gn_command.append('--ninja-extra-args=-j%s' % ninja_jobs)
+            gn_command.append('--ninja-executable=autoninja')
             if self._settings.has_section('filters'):
                 target_filters = self._settings.values('filters')
                 if target_filters:
                     gn_command.append('--filters=%s' % ';'.join(target_filters))
-        # TODO(justincohen): --check is currently failing in crashpad.
-        # else:
-        # gn_command.append('--check')
+        else:
+            gn_command.append('--check')
         gn_command.append('gen')
         gn_command.append('//%s' % os.path.relpath(os.path.abspath(out_path),
                                                    os.path.abspath(src_path)))
@@ -296,6 +315,13 @@
                         dest='import_rules',
                         default=[],
                         help='path to file defining default gn variables')
+    parser.add_argument('--gn-path',
+                        default=None,
+                        help='path to gn binary (default: look up in $PATH)')
+    parser.add_argument(
+        '--build-dir',
+        default='out',
+        help='path where the build should be created (default: %(default)s)')
     args = parser.parse_args(args)
 
     # Load configuration (first global and then any user overrides).
@@ -320,25 +346,16 @@
                          settings.getstring('build', 'arch'))
         sys.exit(1)
 
-    if settings.getboolean('goma', 'enabled'):
-        if settings.getint('xcode', 'jobs') < 0:
-            sys.stderr.write('ERROR: invalid value for xcode.jobs: %s\n' %
-                             settings.get('xcode', 'jobs'))
+    # Find path to gn binary either from command-line or in PATH.
+    if args.gn_path:
+        gn_path = args.gn_path
+    else:
+        gn_path = FindGn()
+        if gn_path is None:
+            sys.stderr.write('ERROR: cannot find gn in PATH\n')
             sys.exit(1)
-        goma_install = os.path.expanduser(settings.getstring('goma', 'install'))
-        if not os.path.isdir(goma_install):
-            sys.stderr.write('WARNING: goma.install directory not found: %s\n' %
-                             settings.get('goma', 'install'))
-            sys.stderr.write('WARNING: disabling goma\n')
-            settings.set('goma', 'enabled', 'false')
 
-    # Find gn binary in PATH.
-    gn_path = FindGn()
-    if gn_path is None:
-        sys.stderr.write('ERROR: cannot find gn in PATH\n')
-        sys.exit(1)
-
-    out_dir = os.path.join(args.root, 'out')
+    out_dir = os.path.join(args.root, args.build_dir)
     if not os.path.isdir(out_dir):
         os.makedirs(out_dir)