1#!/usr/bin/env python
2
3# Copyright 2020 The Crashpad Authors. All rights reserved.
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#     http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16
17
18import argparse
19import convert_gn_xcodeproj
20import errno
21import os
22import re
23import shutil
24import subprocess
25import sys
26import tempfile
27import ConfigParser
28
29try:
30  import cStringIO as StringIO
31except ImportError:
32  import StringIO
33
34
35SUPPORTED_TARGETS = ('iphoneos', 'iphonesimulator')
36SUPPORTED_CONFIGS = ('Debug', 'Release', 'Profile', 'Official', 'Coverage')
37
38
39class ConfigParserWithStringInterpolation(ConfigParser.SafeConfigParser):
40
41  '''A .ini file parser that supports strings and environment variables.'''
42
43  ENV_VAR_PATTERN = re.compile(r'\$([A-Za-z0-9_]+)')
44
45  def values(self, section):
46    return map(
47        lambda (k, v): self._UnquoteString(self._ExpandEnvVar(v)),
48        ConfigParser.SafeConfigParser.items(self, section))
49
50  def getstring(self, section, option):
51    return self._UnquoteString(self._ExpandEnvVar(self.get(section, option)))
52
53  def _UnquoteString(self, string):
54    if not string or string[0] != '"' or string[-1] != '"':
55      return string
56    return string[1:-1]
57
58  def _ExpandEnvVar(self, value):
59    match = self.ENV_VAR_PATTERN.search(value)
60    if not match:
61      return value
62    name, (begin, end) = match.group(1), match.span(0)
63    prefix, suffix = value[:begin], self._ExpandEnvVar(value[end:])
64    return prefix + os.environ.get(name, '') + suffix
65
66class GnGenerator(object):
67
68  '''Holds configuration for a build and method to generate gn default files.'''
69
70  FAT_BUILD_DEFAULT_ARCH = '64-bit'
71
72  TARGET_CPU_VALUES = {
73    'iphoneos': {
74      '32-bit': '"arm"',
75      '64-bit': '"arm64"',
76    },
77    'iphonesimulator': {
78      '32-bit': '"x86"',
79      '64-bit': '"x64"',
80    }
81  }
82
83  def __init__(self, settings, config, target):
84    assert target in SUPPORTED_TARGETS
85    assert config in SUPPORTED_CONFIGS
86    self._settings = settings
87    self._config = config
88    self._target = target
89
90  def _GetGnArgs(self):
91    """Build the list of arguments to pass to gn.
92
93    Returns:
94      A list of tuple containing gn variable names and variable values (it
95      is not a dictionary as the order needs to be preserved).
96    """
97    args = []
98
99    args.append(('is_debug', self._config in ('Debug', 'Coverage')))
100
101    if os.environ.get('FORCE_MAC_TOOLCHAIN', '0') == '1':
102      args.append(('use_system_xcode', False))
103
104    cpu_values = self.TARGET_CPU_VALUES[self._target]
105    build_arch = self._settings.getstring('build', 'arch')
106    if build_arch == 'fat':
107      target_cpu = cpu_values[self.FAT_BUILD_DEFAULT_ARCH]
108      args.append(('target_cpu', target_cpu))
109      args.append(('additional_target_cpus',
110          [cpu for cpu in cpu_values.itervalues() if cpu != target_cpu]))
111    else:
112      args.append(('target_cpu', cpu_values[build_arch]))
113
114    # Add user overrides after the other configurations so that they can
115    # refer to them and override them.
116    args.extend(self._settings.items('gn_args'))
117    return args
118
119
120  def Generate(self, gn_path, root_path, out_path):
121    buf = StringIO.StringIO()
122    self.WriteArgsGn(buf)
123    WriteToFileIfChanged(
124        os.path.join(out_path, 'args.gn'),
125        buf.getvalue(),
126        overwrite=True)
127
128    subprocess.check_call(
129        self.GetGnCommand(gn_path, root_path, out_path, True))
130
131  def CreateGnRules(self, gn_path, root_path, out_path):
132    buf = StringIO.StringIO()
133    self.WriteArgsGn(buf)
134    WriteToFileIfChanged(
135        os.path.join(out_path, 'args.gn'),
136        buf.getvalue(),
137        overwrite=True)
138
139    buf = StringIO.StringIO()
140    gn_command = self.GetGnCommand(gn_path, root_path, out_path, False)
141    self.WriteBuildNinja(buf, gn_command)
142    WriteToFileIfChanged(
143        os.path.join(out_path, 'build.ninja'),
144        buf.getvalue(),
145        overwrite=False)
146
147    buf = StringIO.StringIO()
148    self.WriteBuildNinjaDeps(buf)
149    WriteToFileIfChanged(
150        os.path.join(out_path, 'build.ninja.d'),
151        buf.getvalue(),
152        overwrite=False)
153
154  def WriteArgsGn(self, stream):
155    stream.write('# This file was generated by setup-gn.py. Do not edit\n')
156    stream.write('# but instead use ~/.setup-gn or $repo/.setup-gn files\n')
157    stream.write('# to configure settings.\n')
158    stream.write('\n')
159
160    if self._settings.has_section('$imports$'):
161      for import_rule in self._settings.values('$imports$'):
162        stream.write('import("%s")\n' % import_rule)
163      stream.write('\n')
164
165    gn_args = self._GetGnArgs()
166    for name, value in gn_args:
167      if isinstance(value, bool):
168        stream.write('%s = %s\n' % (name, str(value).lower()))
169      elif isinstance(value, list):
170        stream.write('%s = [%s' % (name, '\n' if len(value) > 1 else ''))
171        if len(value) == 1:
172          prefix = ' '
173          suffix = ' '
174        else:
175          prefix = '  '
176          suffix = ',\n'
177        for item in value:
178          if isinstance(item, bool):
179            stream.write('%s%s%s' % (prefix, str(item).lower(), suffix))
180          else:
181            stream.write('%s%s%s' % (prefix, item, suffix))
182        stream.write(']\n')
183      else:
184        stream.write('%s = %s\n' % (name, value))
185
186  def WriteBuildNinja(self, stream, gn_command):
187    stream.write('rule gn\n')
188    stream.write('  command = %s\n' % NinjaEscapeCommand(gn_command))
189    stream.write('  description = Regenerating ninja files\n')
190    stream.write('\n')
191    stream.write('build build.ninja: gn\n')
192    stream.write('  generator = 1\n')
193    stream.write('  depfile = build.ninja.d\n')
194
195  def WriteBuildNinjaDeps(self, stream):
196    stream.write('build.ninja: nonexistant_file.gn\n')
197
198  def GetGnCommand(self, gn_path, src_path, out_path, generate_xcode_project):
199    gn_command = [ gn_path, '--root=%s' % os.path.realpath(src_path), '-q' ]
200    if generate_xcode_project:
201      gn_command.append('--ide=xcode')
202      gn_command.append('--root-target=gn_all')
203      if self._settings.getboolean('goma', 'enabled'):
204        ninja_jobs = self._settings.getint('xcode', 'jobs') or 200
205        gn_command.append('--ninja-extra-args=-j%s' % ninja_jobs)
206      if self._settings.has_section('filters'):
207        target_filters = self._settings.values('filters')
208        if target_filters:
209          gn_command.append('--filters=%s' % ';'.join(target_filters))
210    # TODO(justincohen): --check is currently failing in crashpad.
211    # else:
212      # gn_command.append('--check')
213    gn_command.append('gen')
214    gn_command.append('//%s' %
215        os.path.relpath(os.path.abspath(out_path), os.path.abspath(src_path)))
216    return gn_command
217
218
219def WriteToFileIfChanged(filename, content, overwrite):
220  '''Write |content| to |filename| if different. If |overwrite| is False
221  and the file already exists it is left untouched.'''
222  if os.path.exists(filename):
223    if not overwrite:
224      return
225    with open(filename) as file:
226      if file.read() == content:
227        return
228  if not os.path.isdir(os.path.dirname(filename)):
229    os.makedirs(os.path.dirname(filename))
230  with open(filename, 'w') as file:
231    file.write(content)
232
233
234def NinjaNeedEscape(arg):
235  '''Returns True if |arg| needs to be escaped when written to .ninja file.'''
236  return ':' in arg or '*' in arg or ';' in arg
237
238
239def NinjaEscapeCommand(command):
240  '''Escapes |command| in order to write it to .ninja file.'''
241  result = []
242  for arg in command:
243    if NinjaNeedEscape(arg):
244      arg = arg.replace(':', '$:')
245      arg = arg.replace(';', '\\;')
246      arg = arg.replace('*', '\\*')
247    else:
248      result.append(arg)
249  return ' '.join(result)
250
251
252def FindGn():
253  '''Returns absolute path to gn binary looking at the PATH env variable.'''
254  for path in os.environ['PATH'].split(os.path.pathsep):
255    gn_path = os.path.join(path, 'gn')
256    if os.path.isfile(gn_path) and os.access(gn_path, os.X_OK):
257      return gn_path
258  return None
259
260
261def GenerateXcodeProject(gn_path, root_dir, out_dir, settings):
262  '''Convert GN generated Xcode project into multi-configuration Xcode
263  project.'''
264
265  temp_path = tempfile.mkdtemp(prefix=os.path.abspath(
266      os.path.join(out_dir, '_temp')))
267  try:
268    generator = GnGenerator(settings, 'Debug', 'iphonesimulator')
269    generator.Generate(gn_path, root_dir, temp_path)
270    convert_gn_xcodeproj.ConvertGnXcodeProject(
271        root_dir,
272        os.path.join(temp_path),
273        os.path.join(out_dir, 'build'),
274        SUPPORTED_CONFIGS)
275  finally:
276    if os.path.exists(temp_path):
277      shutil.rmtree(temp_path)
278
279
280def GenerateGnBuildRules(gn_path, root_dir, out_dir, settings):
281  '''Generates all template configurations for gn.'''
282  for config in SUPPORTED_CONFIGS:
283    for target in SUPPORTED_TARGETS:
284      build_dir = os.path.join(out_dir, '%s-%s' % (config, target))
285      generator = GnGenerator(settings, config, target)
286      generator.CreateGnRules(gn_path, root_dir, build_dir)
287
288
289def Main(args):
290  default_root = os.path.normpath(os.path.join(
291      os.path.dirname(__file__), os.pardir, os.pardir))
292
293  parser = argparse.ArgumentParser(
294      description='Generate build directories for use with gn.')
295  parser.add_argument(
296      'root', default=default_root, nargs='?',
297      help='root directory where to generate multiple out configurations')
298  parser.add_argument(
299      '--import', action='append', dest='import_rules', default=[],
300      help='path to file defining default gn variables')
301  args = parser.parse_args(args)
302
303  # Load configuration (first global and then any user overrides).
304  settings = ConfigParserWithStringInterpolation()
305  settings.read([
306      os.path.splitext(__file__)[0] + '.config',
307      os.path.expanduser('~/.setup-gn'),
308  ])
309
310  # Add private sections corresponding to --import argument.
311  if args.import_rules:
312    settings.add_section('$imports$')
313    for i, import_rule in enumerate(args.import_rules):
314      if not import_rule.startswith('//'):
315        import_rule = '//%s' % os.path.relpath(
316            os.path.abspath(import_rule), os.path.abspath(args.root))
317      settings.set('$imports$', '$rule%d$' % i, import_rule)
318
319  # Validate settings.
320  if settings.getstring('build', 'arch') not in ('64-bit', '32-bit', 'fat'):
321    sys.stderr.write('ERROR: invalid value for build.arch: %s\n' %
322        settings.getstring('build', 'arch'))
323    sys.exit(1)
324
325  if settings.getboolean('goma', 'enabled'):
326    if settings.getint('xcode', 'jobs') < 0:
327      sys.stderr.write('ERROR: invalid value for xcode.jobs: %s\n' %
328          settings.get('xcode', 'jobs'))
329      sys.exit(1)
330    goma_install = os.path.expanduser(settings.getstring('goma', 'install'))
331    if not os.path.isdir(goma_install):
332      sys.stderr.write('WARNING: goma.install directory not found: %s\n' %
333          settings.get('goma', 'install'))
334      sys.stderr.write('WARNING: disabling goma\n')
335      settings.set('goma', 'enabled', 'false')
336
337  # Find gn binary in PATH.
338  gn_path = FindGn()
339  if gn_path is None:
340    sys.stderr.write('ERROR: cannot find gn in PATH\n')
341    sys.exit(1)
342
343  out_dir = os.path.join(args.root, 'out')
344  if not os.path.isdir(out_dir):
345    os.makedirs(out_dir)
346
347  GenerateXcodeProject(gn_path, args.root, out_dir, settings)
348  GenerateGnBuildRules(gn_path, args.root, out_dir, settings)
349
350
351if __name__ == '__main__':
352  sys.exit(Main(sys.argv[1:]))
353