1#!/usr/bin/python
2# Copyright 2016 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6import argparse
7import convert_gn_xcodeproj
8import errno
9import os
10import re
11import shutil
12import subprocess
13import sys
14import tempfile
15
16try:
17  import configparser
18except ImportError:
19  import ConfigParser as configparser
20
21try:
22  import StringIO as io
23except ImportError:
24  import io
25
26
27SUPPORTED_TARGETS = ('iphoneos', 'iphonesimulator', 'maccatalyst')
28SUPPORTED_CONFIGS = ('Debug', 'Release', 'Profile', 'Official', 'Coverage')
29
30
31class ConfigParserWithStringInterpolation(configparser.SafeConfigParser):
32
33  '''A .ini file parser that supports strings and environment variables.'''
34
35  ENV_VAR_PATTERN = re.compile(r'\$([A-Za-z0-9_]+)')
36
37  def values(self, section):
38    return map(
39        lambda kv: self._UnquoteString(self._ExpandEnvVar(kv[1])),
40        configparser.ConfigParser.items(self, section))
41
42  def getstring(self, section, option):
43    return self._UnquoteString(self._ExpandEnvVar(self.get(section, option)))
44
45  def _UnquoteString(self, string):
46    if not string or string[0] != '"' or string[-1] != '"':
47      return string
48    return string[1:-1]
49
50  def _ExpandEnvVar(self, value):
51    match = self.ENV_VAR_PATTERN.search(value)
52    if not match:
53      return value
54    name, (begin, end) = match.group(1), match.span(0)
55    prefix, suffix = value[:begin], self._ExpandEnvVar(value[end:])
56    return prefix + os.environ.get(name, '') + suffix
57
58class GnGenerator(object):
59
60  '''Holds configuration for a build and method to generate gn default files.'''
61
62  FAT_BUILD_DEFAULT_ARCH = '64-bit'
63
64  TARGET_CPU_VALUES = {
65    'iphoneos': '"arm64"',
66    'iphonesimulator': '"x64"',
67    'maccatalyst': '"x64"',
68  }
69
70  TARGET_ENVIRONMENT_VALUES = {
71    'iphoneos': '"device"',
72    'iphonesimulator': '"simulator"',
73    'maccatalyst': '"catalyst"'
74  }
75
76  def __init__(self, settings, config, target):
77    assert target in SUPPORTED_TARGETS
78    assert config in SUPPORTED_CONFIGS
79    self._settings = settings
80    self._config = config
81    self._target = target
82
83  def _GetGnArgs(self):
84    """Build the list of arguments to pass to gn.
85
86    Returns:
87      A list of tuple containing gn variable names and variable values (it
88      is not a dictionary as the order needs to be preserved).
89    """
90    args = []
91
92    # build/config/ios/ios_sdk.gni asserts that goma is not enabled when
93    # building Official, so ignore the value of goma.enabled when creating
94    # args.gn for Official.
95    if self._config != 'Official':
96      if self._settings.getboolean('goma', 'enabled'):
97        args.append(('use_goma', True))
98        goma_dir = self._settings.getstring('goma', 'install')
99        if goma_dir:
100          args.append(('goma_dir', '"%s"' % os.path.expanduser(goma_dir)))
101
102    args.append(('target_os', '"ios"'))
103    args.append(('is_debug', self._config in ('Debug', 'Coverage')))
104    args.append(('enable_dsyms', self._config in ('Profile', 'Official')))
105    args.append(('enable_stripping', 'enable_dsyms'))
106    args.append(('is_official_build', self._config == 'Official'))
107    args.append(('is_chrome_branded', 'is_official_build'))
108    args.append(('use_clang_coverage', self._config == 'Coverage'))
109    args.append(('is_component_build', False))
110
111    if os.environ.get('FORCE_MAC_TOOLCHAIN', '0') == '1':
112      args.append(('use_system_xcode', False))
113
114    args.append(('target_cpu', self.TARGET_CPU_VALUES[self._target]))
115    args.append((
116        'target_environment',
117        self.TARGET_ENVIRONMENT_VALUES[self._target]))
118
119    if self._target == 'maccatalyst':
120      # Building for "catalyst" environment has not been open-sourced thus can't
121      # use ToT clang and need to use Xcode's version instead. This version of
122      # clang does not generate the same warning as ToT clang, so do not treat
123      # warnings as errors.
124      # TODO(crbug.com/1145947): remove once clang ToT supports "macabi".
125      args.append(('use_xcode_clang', True))
126      args.append(('treat_warnings_as_errors', False))
127
128      # The "catalyst" environment is only supported from iOS 13.0 SDK. Until
129      # Chrome uses this SDK, it needs to be overridden for "catalyst" builds.
130      args.append(('ios_deployment_target', '"13.0"'))
131
132    # Add user overrides after the other configurations so that they can
133    # refer to them and override them.
134    args.extend(self._settings.items('gn_args'))
135    return args
136
137
138  def Generate(self, gn_path, root_path, build_dir):
139    self.WriteArgsGn(build_dir)
140    subprocess.check_call(
141        self.GetGnCommand(gn_path, root_path, build_dir, True))
142
143  def CreateGnRules(self, gn_path, root_path, build_dir):
144    gn_command = self.GetGnCommand(gn_path, root_path, build_dir, False)
145    self.WriteArgsGn(build_dir)
146    self.WriteBuildNinja(gn_command, build_dir)
147    self.WriteBuildNinjaDeps(build_dir)
148
149  def WriteArgsGn(self, build_dir):
150    with open(os.path.join(build_dir, 'args.gn'), 'w') as stream:
151      stream.write('# This file was generated by setup-gn.py. Do not edit\n')
152      stream.write('# but instead use ~/.setup-gn or $repo/.setup-gn files\n')
153      stream.write('# to configure settings.\n')
154      stream.write('\n')
155
156      if self._target != 'maccatalyst':
157        if self._settings.has_section('$imports$'):
158          for import_rule in self._settings.values('$imports$'):
159            stream.write('import("%s")\n' % import_rule)
160          stream.write('\n')
161
162      gn_args = self._GetGnArgs()
163      for name, value in gn_args:
164        if isinstance(value, bool):
165          stream.write('%s = %s\n' % (name, str(value).lower()))
166        elif isinstance(value, list):
167          stream.write('%s = [%s' % (name, '\n' if len(value) > 1 else ''))
168          if len(value) == 1:
169            prefix = ' '
170            suffix = ' '
171          else:
172            prefix = '  '
173            suffix = ',\n'
174          for item in value:
175            if isinstance(item, bool):
176              stream.write('%s%s%s' % (prefix, str(item).lower(), suffix))
177            else:
178              stream.write('%s%s%s' % (prefix, item, suffix))
179          stream.write(']\n')
180        else:
181          # ConfigParser removes quote around empty string which confuse
182          # `gn gen` so restore them.
183          if not value:
184            value = '""'
185          stream.write('%s = %s\n' % (name, value))
186
187  def WriteBuildNinja(self, gn_command, build_dir):
188    with open(os.path.join(build_dir, 'build.ninja'), 'w') as stream:
189      stream.write('ninja_required_version = 1.7.2\n')
190      stream.write('\n')
191      stream.write('rule gn\n')
192      stream.write('  command = %s\n' % NinjaEscapeCommand(gn_command))
193      stream.write('  description = Regenerating ninja files\n')
194      stream.write('\n')
195      stream.write('build build.ninja: gn\n')
196      stream.write('  generator = 1\n')
197      stream.write('  depfile = build.ninja.d\n')
198
199  def WriteBuildNinjaDeps(self, build_dir):
200    with open(os.path.join(build_dir, 'build.ninja.d'), 'w') as stream:
201      stream.write('build.ninja: nonexistant_file.gn\n')
202
203  def GetGnCommand(self, gn_path, src_path, out_path, generate_xcode_project):
204    gn_command = [ gn_path, '--root=%s' % os.path.realpath(src_path), '-q' ]
205    if generate_xcode_project:
206      gn_command.append('--ide=xcode')
207      gn_command.append('--ninja-executable=autoninja')
208      if self._settings.has_section('filters'):
209        target_filters = self._settings.values('filters')
210        if target_filters:
211          gn_command.append('--filters=%s' % ';'.join(target_filters))
212    else:
213      gn_command.append('--check')
214    gn_command.append('gen')
215    gn_command.append('//%s' %
216        os.path.relpath(os.path.abspath(out_path), os.path.abspath(src_path)))
217    return gn_command
218
219
220def NinjaNeedEscape(arg):
221  '''Returns True if |arg| needs to be escaped when written to .ninja file.'''
222  return ':' in arg or '*' in arg or ';' in arg
223
224
225def NinjaEscapeCommand(command):
226  '''Escapes |command| in order to write it to .ninja file.'''
227  result = []
228  for arg in command:
229    if NinjaNeedEscape(arg):
230      arg = arg.replace(':', '$:')
231      arg = arg.replace(';', '\\;')
232      arg = arg.replace('*', '\\*')
233    else:
234      result.append(arg)
235  return ' '.join(result)
236
237
238def FindGn():
239  '''Returns absolute path to gn binary looking at the PATH env variable.'''
240  for path in os.environ['PATH'].split(os.path.pathsep):
241    gn_path = os.path.join(path, 'gn')
242    if os.path.isfile(gn_path) and os.access(gn_path, os.X_OK):
243      return gn_path
244  return None
245
246
247def GenerateXcodeProject(gn_path, root_dir, out_dir, settings):
248  '''Convert GN generated Xcode project into multi-configuration Xcode
249  project.'''
250
251  temp_path = tempfile.mkdtemp(prefix=os.path.abspath(
252      os.path.join(out_dir, '_temp')))
253  try:
254    generator = GnGenerator(settings, 'Debug', 'iphonesimulator')
255    generator.Generate(gn_path, root_dir, temp_path)
256    convert_gn_xcodeproj.ConvertGnXcodeProject(
257        root_dir,
258        os.path.join(temp_path),
259        os.path.join(out_dir, 'build'),
260        SUPPORTED_CONFIGS)
261  finally:
262    if os.path.exists(temp_path):
263      shutil.rmtree(temp_path)
264
265
266def GenerateGnBuildRules(gn_path, root_dir, out_dir, settings):
267  '''Generates all template configurations for gn.'''
268  for config in SUPPORTED_CONFIGS:
269    for target in SUPPORTED_TARGETS:
270      build_dir = os.path.join(out_dir, '%s-%s' % (config, target))
271      if not os.path.isdir(build_dir):
272        os.makedirs(build_dir)
273
274      generator = GnGenerator(settings, config, target)
275      generator.CreateGnRules(gn_path, root_dir, build_dir)
276
277
278def Main(args):
279  default_root = os.path.normpath(os.path.join(
280      os.path.dirname(__file__), os.pardir, os.pardir, os.pardir))
281
282  parser = argparse.ArgumentParser(
283      description='Generate build directories for use with gn.')
284  parser.add_argument(
285      'root', default=default_root, nargs='?',
286      help='root directory where to generate multiple out configurations')
287  parser.add_argument(
288      '--import', action='append', dest='import_rules', default=[],
289      help='path to file defining default gn variables')
290  parser.add_argument(
291      '--gn-path', default=None,
292      help='path to gn binary (default: look up in $PATH)')
293  parser.add_argument(
294      '--build-dir', default='out',
295      help='path where the build should be created (default: %(default)s)')
296  args = parser.parse_args(args)
297
298  # Load configuration (first global and then any user overrides).
299  settings = ConfigParserWithStringInterpolation()
300  settings.read([
301      os.path.splitext(__file__)[0] + '.config',
302      os.path.expanduser('~/.setup-gn'),
303  ])
304
305  # Add private sections corresponding to --import argument.
306  if args.import_rules:
307    settings.add_section('$imports$')
308    for i, import_rule in enumerate(args.import_rules):
309      if not import_rule.startswith('//'):
310        import_rule = '//%s' % os.path.relpath(
311            os.path.abspath(import_rule), os.path.abspath(args.root))
312      settings.set('$imports$', '$rule%d$' % i, import_rule)
313
314  # Validate settings.
315  if settings.getstring('build', 'arch') not in ('64-bit', '32-bit', 'fat'):
316    sys.stderr.write('ERROR: invalid value for build.arch: %s\n' %
317        settings.getstring('build', 'arch'))
318    sys.exit(1)
319
320  # Find path to gn binary either from command-line or in PATH.
321  if args.gn_path:
322    gn_path = args.gn_path
323  else:
324    gn_path = FindGn()
325    if gn_path is None:
326      sys.stderr.write('ERROR: cannot find gn in PATH\n')
327      sys.exit(1)
328
329  out_dir = os.path.join(args.root, args.build_dir)
330  if not os.path.isdir(out_dir):
331    os.makedirs(out_dir)
332
333  GenerateXcodeProject(gn_path, args.root, out_dir, settings)
334  GenerateGnBuildRules(gn_path, args.root, out_dir, settings)
335
336
337if __name__ == '__main__':
338  sys.exit(Main(sys.argv[1:]))
339