chromium/ios/build/tools/setup-gn.py

#!/usr/bin/env python3
# Copyright 2016 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import argparse
import configparser
import convert_gn_xcodeproj
import errno
import io
import os
import platform
import re
import shutil
import subprocess
import sys
import tempfile


SUPPORTED_TARGETS = ('iphoneos', 'iphonesimulator', 'maccatalyst')
SUPPORTED_CONFIGS = ('Debug', 'Release', 'Profile', 'Official')
ADDITIONAL_FILE_ROOTS = ('//ios', '//ios_internal', '//docs', '//components')
ADDITIONAL_FILES_PATTERNS = ('*.md', '*_google_chrome_*.grd', 'OWNERS', 'DEPS')

# Pattern matching lines from ~/.lldbinit that must not be copied to the
# generated .lldbinit file. They match what the user were told to add to
# their global ~/.lldbinit file before setup-gn.py was updated to generate
# a project specific file and thus must not be copied as they would cause
# the settings to be overwritten.
LLDBINIT_SKIP_PATTERNS = (
    re.compile('^script sys.path\\[:0\\] = \\[\'.*/src/tools/lldb\'\\]$'),
    re.compile('^script import lldbinit$'),
    re.compile('^settings append target.source-map .* /google/src/.*$'),
)


def HostCpuArch():
  '''Returns the arch of the host cpu for GN.'''
  HOST_CPU_ARCH = {
    'arm64': '"arm64"',
    'x86_64': '"x64"',
  }
  return HOST_CPU_ARCH[platform.machine()]


class ConfigParserWithStringInterpolation(configparser.ConfigParser):

  '''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 filter(
        lambda val: val != '',
        map(lambda kv: self._UnquoteString(self._ExpandEnvVar(kv[1])),
            configparser.ConfigParser.items(self, section)))

  def getstring(self, section, option, fallback=''):
    try:
      raw_value = self.get(section, option)
    except configparser.NoOptionError:
      return fallback
    return self._UnquoteString(self._ExpandEnvVar(raw_value))

  def getboolean(self, section, option, fallback=False):
    try:
      return super().getboolean(section, option)
    except configparser.NoOptionError:
      return fallback

  def _UnquoteString(self, string):
    if not string or string[0] != '"' or string[-1] != '"':
      return string
    return string[1:-1]

  def _ExpandEnvVar(self, value):
    match = self.ENV_VAR_PATTERN.search(value)
    if not match:
      return value
    name, (begin, end) = match.group(1), match.span(0)
    prefix, suffix = value[:begin], self._ExpandEnvVar(value[end:])
    return prefix + os.environ.get(name, '') + suffix


class GnGenerator(object):

  '''Holds configuration for a build and method to generate gn default files.'''

  FAT_BUILD_DEFAULT_ARCH = '64-bit'

  TARGET_CPU_VALUES = {
    'iphoneos': '"arm64"',
    'iphonesimulator': HostCpuArch(),
    'maccatalyst': HostCpuArch(),
  }

  TARGET_ENVIRONMENT_VALUES = {
    'iphoneos': '"device"',
    'iphonesimulator': '"simulator"',
    'maccatalyst': '"catalyst"'
  }

  def __init__(self, settings, config, target):
    assert target in SUPPORTED_TARGETS
    assert config in SUPPORTED_CONFIGS
    self._settings = settings
    self._config = config
    self._target = target

  def _GetGnArgs(self):
    """Build the list of arguments to pass to gn.

    Returns:
      A list of tuple containing gn variable names and variable values (it
      is not a dictionary as the order needs to be preserved).
    """
    args = []

    is_debug = self._config == 'Debug'
    official = self._config == 'Official'
    is_optim = self._config in ('Profile', 'Official')

    args.append(('target_os', '"ios"'))
    args.append(('is_debug', is_debug))
    args.append(('enable_dsyms', is_optim))
    args.append(('enable_stripping', is_optim))
    args.append(('is_official_build', is_optim))
    args.append(('is_chrome_branded', official))

    if os.environ.get('FORCE_MAC_TOOLCHAIN', '0') == '1':
      args.append(('use_system_xcode', False))

    args.append(('target_cpu', self.TARGET_CPU_VALUES[self._target]))
    args.append((
        'target_environment',
        self.TARGET_ENVIRONMENT_VALUES[self._target]))

    # Add user overrides after the other configurations so that they can
    # refer to them and override them.
    args.extend(self._settings.items('gn_args'))
    return args


  def Generate(self, gn_path, proj_name, root_path, build_dir):
    self.WriteArgsGn(build_dir, xcode_project_name=proj_name)
    subprocess.check_call(self.GetGnCommand(
        gn_path, root_path, build_dir, xcode_project_name=proj_name))

  def CreateGnRules(self, gn_path, root_path, build_dir):
    gn_command = self.GetGnCommand(gn_path, root_path, build_dir)
    self.WriteArgsGn(build_dir)
    self.WriteBuildNinja(gn_command, build_dir)
    self.WriteBuildNinjaDeps(build_dir)

  def WriteArgsGn(self, build_dir, xcode_project_name=None):
    with open(os.path.join(build_dir, 'args.gn'), 'w') as stream:
      stream.write('# This file was generated by setup-gn.py. Do not edit\n')
      stream.write('# but instead use ~/.setup-gn or $repo/.setup-gn files\n')
      stream.write('# to configure settings.\n')
      stream.write('\n')

      if self._target != 'maccatalyst':
        if self._settings.has_section('$imports$'):
          for import_rule in self._settings.values('$imports$'):
            stream.write('import("%s")\n' % import_rule)
          stream.write('\n')

      gn_args = self._GetGnArgs()

      for name, value in gn_args:
        if isinstance(value, bool):
          stream.write('%s = %s\n' % (name, str(value).lower()))
        elif isinstance(value, list):
          stream.write('%s = [%s' % (name, '\n' if len(value) > 1 else ''))
          if len(value) == 1:
            prefix = ' '
            suffix = ' '
          else:
            prefix = '  '
            suffix = ',\n'
          for item in value:
            if isinstance(item, bool):
              stream.write('%s%s%s' % (prefix, str(item).lower(), suffix))
            else:
              stream.write('%s%s%s' % (prefix, item, suffix))
          stream.write(']\n')
        else:
          # ConfigParser removes quote around empty string which confuse
          # `gn gen` so restore them.
          if not value:
            value = '""'
          stream.write('%s = %s\n' % (name, value))

  def WriteBuildNinja(self, gn_command, build_dir):
    with open(os.path.join(build_dir, 'build.ninja'), 'w') as stream:
      stream.write('ninja_required_version = 1.7.2\n')
      stream.write('\n')
      stream.write('rule gn\n')
      stream.write('  command = %s\n' % NinjaEscapeCommand(gn_command))
      stream.write('  description = Regenerating ninja files\n')
      stream.write('\n')
      stream.write('build build.ninja.stamp: gn\n')
      stream.write('  generator = 1\n')
      stream.write('  depfile = build.ninja.d\n')
      stream.write('\n')
      stream.write('build build.ninja: phony build.ninja.stamp\n')
      stream.write('  generator = 1\n')
      stream.write('\n')

  def WriteBuildNinjaDeps(self, build_dir):
    with open(os.path.join(build_dir, 'build.ninja.d'), 'w') as stream:
      stream.write('build.ninja: nonexistant_file.gn\n')

  def GetGnCommand(self, gn_path, src_path, out_path, xcode_project_name=None):
    gn_command = [ gn_path, '--root=%s' % os.path.realpath(src_path), '-q' ]
    if xcode_project_name is not None:
      gn_command.append('--ide=xcode')
      gn_command.append('--ninja-executable=autoninja')
      gn_command.append('--xcode-build-system=new')
      gn_command.append('--xcode-project=%s' % xcode_project_name)
      gn_command.append('--xcode-additional-files-patterns=' +
                        ';'.join(ADDITIONAL_FILES_PATTERNS))
      gn_command.append(
          '--xcode-additional-files-roots=' + ';'.join(ADDITIONAL_FILE_ROOTS))
      gn_command.append('--xcode-configs=' + ';'.join(SUPPORTED_CONFIGS))
      gn_command.append('--xcode-config-build-dir='
                        '//out/${CONFIGURATION}${EFFECTIVE_PLATFORM_NAME}')
      use_blink = self._settings.getboolean('gn_args', 'use_blink')
      if self._settings.has_section('filters') and not use_blink:
        target_filters = self._settings.values('filters')
        if target_filters:
          gn_command.append('--filters=%s' % ';'.join(target_filters))
    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)))
    return gn_command


def NinjaNeedEscape(arg):
  '''Returns True if |arg| needs to be escaped when written to .ninja file.'''
  return ':' in arg or '*' in arg or ';' in arg


def NinjaEscapeCommand(command):
  '''Escapes |command| in order to write it to .ninja file.'''
  result = []
  for arg in command:
    if NinjaNeedEscape(arg):
      arg = arg.replace(':', '$:')
      arg = arg.replace(';', '\\;')
      arg = arg.replace('*', '\\*')
    else:
      result.append(arg)
  return ' '.join(result)


def FindGn():
  '''Returns absolute path to gn binary looking at the PATH env variable.'''
  for path in os.environ['PATH'].split(os.path.pathsep):
    gn_path = os.path.join(path, 'gn')
    if os.path.isfile(gn_path) and os.access(gn_path, os.X_OK):
      return gn_path
  return None


def GenerateXcodeProject(gn_path, root_dir, proj_name, out_dir, settings):
  '''Generate Xcode project with Xcode and convert to multi-configurations.'''
  prefix = os.path.abspath(os.path.join(out_dir, '_temp'))
  temp_path = tempfile.mkdtemp(prefix=prefix)
  try:
    generator = GnGenerator(settings, 'Debug', 'iphonesimulator')
    generator.Generate(gn_path, proj_name, root_dir, temp_path)
    convert_gn_xcodeproj.ConvertGnXcodeProject(
        root_dir,
        '%s.xcodeproj' % proj_name,
        os.path.join(temp_path),
        os.path.join(out_dir, 'build'),
        SUPPORTED_CONFIGS)
  finally:
    if os.path.exists(temp_path):
      shutil.rmtree(temp_path)

def CreateLLDBInitFile(root_dir, out_dir, settings):
  '''
  Generate an .lldbinit file for the project that load the script that fixes
  the mapping of source files (see docs/ios/build_instructions.md#debugging).
  '''
  with open(os.path.join(out_dir, 'build', '.lldbinit'), 'w') as lldbinit:
    lldb_script_dir = os.path.join(os.path.abspath(root_dir), 'tools', 'lldb')
    lldbinit.write('script sys.path[:0] = [\'%s\']\n' % lldb_script_dir)
    lldbinit.write('script import lldbinit\n')

    workspace_name = settings.getstring(
        'gn_args',
        'ios_internal_citc_workspace_name')

    if workspace_name != '':
      username = os.environ['USER']
      for shortname in ('googlemac', 'third_party', 'blaze-out'):
        lldbinit.write('settings append target.source-map %s %s\n' % (
            shortname,
            '/google/src/cloud/%s/%s/google3/%s' % (
                username, workspace_name, shortname)))

    # Append the content of //ios/build/tools/lldbinit.defaults if it exists.
    tools_dir = os.path.join(root_dir, 'ios', 'build', 'tools')
    defaults_lldbinit_path = os.path.join(tools_dir, 'lldbinit.defaults')
    if os.path.isfile(defaults_lldbinit_path):
      with open(defaults_lldbinit_path) as defaults_lldbinit:
        for line in defaults_lldbinit:
          lldbinit.write(line)

    # Append the content of ~/.lldbinit if it exists. Line that look like they
    # are trying to configure source mapping are skipped as they probably date
    # back from when setup-gn.py was not generating an .lldbinit file.
    global_lldbinit_path = os.path.join(os.environ['HOME'], '.lldbinit')
    if os.path.isfile(global_lldbinit_path):
      with open(global_lldbinit_path) as global_lldbinit:
        for line in global_lldbinit:
          if any(pattern.match(line) for pattern in LLDBINIT_SKIP_PATTERNS):
            continue
          lldbinit.write(line)


def GenerateGnBuildRules(gn_path, root_dir, out_dir, settings):
  '''Generates all template configurations for gn.'''
  for config in SUPPORTED_CONFIGS:
    for target in SUPPORTED_TARGETS:
      build_dir = os.path.join(out_dir, '%s-%s' % (config, target))
      if not os.path.isdir(build_dir):
        os.makedirs(build_dir)

      generator = GnGenerator(settings, config, target)
      generator.CreateGnRules(gn_path, root_dir, build_dir)


def Main(args):
  default_root = os.path.normpath(os.path.join(
      os.path.dirname(__file__), os.pardir, os.pardir, os.pardir))

  parser = argparse.ArgumentParser(
      description='Generate build directories for use with gn.')
  parser.add_argument(
      'root', default=default_root, nargs='?',
      help='root directory where to generate multiple out configurations')
  parser.add_argument(
      '--import', action='append', 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)')
  parser.add_argument(
      '--config-path', default=os.path.expanduser('~/.setup-gn'),
      help='path to the user config file (default: %(default)s)')
  parser.add_argument(
      '--project-config-path', default=os.path.join(default_root, os.pardir,
          '.setup-gn'),
      help='path to the project config file (default: %(default)s)')
  parser.add_argument(
      '--system-config-path', default=os.path.splitext(__file__)[0] + '.config',
      help='path to the default config file (default: %(default)s)')
  parser.add_argument(
      '--project-name', default='all', dest='proj_name',
      help='name of the generated Xcode project (default: %(default)s)')
  parser.add_argument(
      '--no-xcode-project', action='store_true', default=False,
      help='do not generate the build directory with XCode project')
  args = parser.parse_args(args)

  # Load configuration (first global and then any user overrides).
  settings = ConfigParserWithStringInterpolation()
  settings.read([
      args.system_config_path,
      args.config_path,
      args.project_config_path,
  ])

  # Add private sections corresponding to --import argument.
  if args.import_rules:
    settings.add_section('$imports$')
    for i, import_rule in enumerate(args.import_rules):
      if not import_rule.startswith('//'):
        import_rule = '//%s' % os.path.relpath(
            os.path.abspath(import_rule), os.path.abspath(args.root))
      settings.set('$imports$', '$rule%d$' % i, import_rule)

  # Validate settings.
  if settings.getstring('build', 'arch') not in ('64-bit', '32-bit', 'fat'):
    sys.stderr.write('ERROR: invalid value for build.arch: %s\n' %
        settings.getstring('build', 'arch'))
    sys.exit(1)

  # 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)

  out_dir = os.path.join(args.root, args.build_dir)
  if not os.path.isdir(out_dir):
    os.makedirs(out_dir)

  if not args.no_xcode_project:
    GenerateXcodeProject(gn_path, args.root, args.proj_name, out_dir, settings)
    CreateLLDBInitFile(args.root, out_dir, settings)
  GenerateGnBuildRules(gn_path, args.root, out_dir, settings)


if __name__ == '__main__':
  sys.exit(Main(sys.argv[1:]))