1# Copyright (c) 2014 Google Inc. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""Xcode-ninja wrapper project file generator.
6
7This updates the data structures passed to the Xcode gyp generator to build
8with ninja instead. The Xcode project itself is transformed into a list of
9executable targets, each with a build step to build with ninja, and a target
10with every source and resource file.  This appears to sidestep some of the
11major performance headaches experienced using complex projects and large number
12of targets within Xcode.
13"""
14
15import errno
16import gyp.generator.ninja
17import os
18import re
19import xml.sax.saxutils
20
21
22def _WriteWorkspace(main_gyp, sources_gyp):
23  """ Create a workspace to wrap main and sources gyp paths. """
24  (build_file_root, build_file_ext) = os.path.splitext(main_gyp)
25  workspace_path = build_file_root + '.xcworkspace'
26  try:
27    os.makedirs(workspace_path)
28  except OSError, e:
29    if e.errno != errno.EEXIST:
30      raise
31  output_string = '<?xml version="1.0" encoding="UTF-8"?>\n' + \
32                  '<Workspace version = "1.0">\n'
33  for gyp_name in [main_gyp, sources_gyp]:
34    name = os.path.splitext(os.path.basename(gyp_name))[0] + '.xcodeproj'
35    name = xml.sax.saxutils.quoteattr("group:" + name)
36    output_string += '  <FileRef location = %s></FileRef>\n' % name
37  output_string += '</Workspace>\n'
38
39  workspace_file = os.path.join(workspace_path, "contents.xcworkspacedata")
40
41  try:
42    with open(workspace_file, 'r') as input_file:
43      input_string = input_file.read()
44      if input_string == output_string:
45        return
46  except IOError:
47    # Ignore errors if the file doesn't exist.
48    pass
49
50  with open(workspace_file, 'w') as output_file:
51    output_file.write(output_string)
52
53def _TargetFromSpec(old_spec, params):
54  """ Create fake target for xcode-ninja wrapper. """
55  # Determine ninja top level build dir (e.g. /path/to/out).
56  ninja_toplevel = None
57  jobs = 0
58  if params:
59    options = params['options']
60    ninja_toplevel = \
61        os.path.join(options.toplevel_dir,
62                     gyp.generator.ninja.ComputeOutputDir(params))
63    jobs = params.get('generator_flags', {}).get('xcode_ninja_jobs', 0)
64
65  target_name = old_spec.get('target_name')
66  product_name = old_spec.get('product_name', target_name)
67
68  ninja_target = {}
69  ninja_target['target_name'] = target_name
70  ninja_target['product_name'] = product_name
71  ninja_target['toolset'] = old_spec.get('toolset')
72  ninja_target['default_configuration'] = old_spec.get('default_configuration')
73  ninja_target['configurations'] = {}
74
75  # Tell Xcode to look in |ninja_toplevel| for build products.
76  new_xcode_settings = {}
77  if ninja_toplevel:
78    new_xcode_settings['CONFIGURATION_BUILD_DIR'] = \
79        "%s/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)" % ninja_toplevel
80
81  if 'configurations' in old_spec:
82    for config in old_spec['configurations'].iterkeys():
83      old_xcode_settings = \
84        old_spec['configurations'][config].get('xcode_settings', {})
85      if 'IPHONEOS_DEPLOYMENT_TARGET' in old_xcode_settings:
86        new_xcode_settings['CODE_SIGNING_REQUIRED'] = "NO"
87        new_xcode_settings['IPHONEOS_DEPLOYMENT_TARGET'] = \
88            old_xcode_settings['IPHONEOS_DEPLOYMENT_TARGET']
89      ninja_target['configurations'][config] = {}
90      ninja_target['configurations'][config]['xcode_settings'] = \
91          new_xcode_settings
92
93  ninja_target['mac_bundle'] = old_spec.get('mac_bundle', 0)
94  ninja_target['ios_app_extension'] = old_spec.get('ios_app_extension', 0)
95  ninja_target['type'] = old_spec['type']
96  if ninja_toplevel:
97    ninja_target['actions'] = [
98      {
99        'action_name': 'Compile and copy %s via ninja' % target_name,
100        'inputs': [],
101        'outputs': [],
102        'action': [
103          'env',
104          'PATH=%s' % os.environ['PATH'],
105          'ninja',
106          '-C',
107          new_xcode_settings['CONFIGURATION_BUILD_DIR'],
108          target_name,
109        ],
110        'message': 'Compile and copy %s via ninja' % target_name,
111      },
112    ]
113    if jobs > 0:
114      ninja_target['actions'][0]['action'].extend(('-j', jobs))
115  return ninja_target
116
117def IsValidTargetForWrapper(target_extras, executable_target_pattern, spec):
118  """Limit targets for Xcode wrapper.
119
120  Xcode sometimes performs poorly with too many targets, so only include
121  proper executable targets, with filters to customize.
122  Arguments:
123    target_extras: Regular expression to always add, matching any target.
124    executable_target_pattern: Regular expression limiting executable targets.
125    spec: Specifications for target.
126  """
127  target_name = spec.get('target_name')
128  # Always include targets matching target_extras.
129  if target_extras is not None and re.search(target_extras, target_name):
130    return True
131
132  # Otherwise just show executable targets.
133  if spec.get('type', '') == 'executable' and \
134     spec.get('product_extension', '') != 'bundle':
135
136    # If there is a filter and the target does not match, exclude the target.
137    if executable_target_pattern is not None:
138      if not re.search(executable_target_pattern, target_name):
139        return False
140    return True
141  return False
142
143def CreateWrapper(target_list, target_dicts, data, params):
144  """Initialize targets for the ninja wrapper.
145
146  This sets up the necessary variables in the targets to generate Xcode projects
147  that use ninja as an external builder.
148  Arguments:
149    target_list: List of target pairs: 'base/base.gyp:base'.
150    target_dicts: Dict of target properties keyed on target pair.
151    data: Dict of flattened build files keyed on gyp path.
152    params: Dict of global options for gyp.
153  """
154  orig_gyp = params['build_files'][0]
155  for gyp_name, gyp_dict in data.iteritems():
156    if gyp_name == orig_gyp:
157      depth = gyp_dict['_DEPTH']
158
159  # Check for custom main gyp name, otherwise use the default CHROMIUM_GYP_FILE
160  # and prepend .ninja before the .gyp extension.
161  generator_flags = params.get('generator_flags', {})
162  main_gyp = generator_flags.get('xcode_ninja_main_gyp', None)
163  if main_gyp is None:
164    (build_file_root, build_file_ext) = os.path.splitext(orig_gyp)
165    main_gyp = build_file_root + ".ninja" + build_file_ext
166
167  # Create new |target_list|, |target_dicts| and |data| data structures.
168  new_target_list = []
169  new_target_dicts = {}
170  new_data = {}
171
172  # Set base keys needed for |data|.
173  new_data[main_gyp] = {}
174  new_data[main_gyp]['included_files'] = []
175  new_data[main_gyp]['targets'] = []
176  new_data[main_gyp]['xcode_settings'] = \
177      data[orig_gyp].get('xcode_settings', {})
178
179  # Normally the xcode-ninja generator includes only valid executable targets.
180  # If |xcode_ninja_executable_target_pattern| is set, that list is reduced to
181  # executable targets that match the pattern. (Default all)
182  executable_target_pattern = \
183      generator_flags.get('xcode_ninja_executable_target_pattern', None)
184
185  # For including other non-executable targets, add the matching target name
186  # to the |xcode_ninja_target_pattern| regular expression. (Default none)
187  target_extras = generator_flags.get('xcode_ninja_target_pattern', None)
188
189  for old_qualified_target in target_list:
190    spec = target_dicts[old_qualified_target]
191    if IsValidTargetForWrapper(target_extras, executable_target_pattern, spec):
192      # Add to new_target_list.
193      target_name = spec.get('target_name')
194      new_target_name = '%s:%s#target' % (main_gyp, target_name)
195      new_target_list.append(new_target_name)
196
197      # Add to new_target_dicts.
198      new_target_dicts[new_target_name] = _TargetFromSpec(spec, params)
199
200      # Add to new_data.
201      for old_target in data[old_qualified_target.split(':')[0]]['targets']:
202        if old_target['target_name'] == target_name:
203          new_data_target = {}
204          new_data_target['target_name'] = old_target['target_name']
205          new_data_target['toolset'] = old_target['toolset']
206          new_data[main_gyp]['targets'].append(new_data_target)
207
208  # Create sources target.
209  sources_target_name = 'sources_for_indexing'
210  sources_target = _TargetFromSpec(
211    { 'target_name' : sources_target_name,
212      'toolset': 'target',
213      'default_configuration': 'Default',
214      'mac_bundle': '0',
215      'type': 'executable'
216    }, None)
217
218  # Tell Xcode to look everywhere for headers.
219  sources_target['configurations'] = {'Default': { 'include_dirs': [ depth ] } }
220
221  sources = []
222  for target, target_dict in target_dicts.iteritems():
223    base =  os.path.dirname(target)
224    files = target_dict.get('sources', []) + \
225            target_dict.get('mac_bundle_resources', [])
226    # Remove files starting with $. These are mostly intermediate files for the
227    # build system.
228    files = [ file for file in files if not file.startswith('$')]
229
230    # Make sources relative to root build file.
231    relative_path = os.path.dirname(main_gyp)
232    sources += [ os.path.relpath(os.path.join(base, file), relative_path)
233                    for file in files ]
234
235  sources_target['sources'] = sorted(set(sources))
236
237  # Put sources_to_index in it's own gyp.
238  sources_gyp = \
239      os.path.join(os.path.dirname(main_gyp), sources_target_name + ".gyp")
240  fully_qualified_target_name = \
241      '%s:%s#target' % (sources_gyp, sources_target_name)
242
243  # Add to new_target_list, new_target_dicts and new_data.
244  new_target_list.append(fully_qualified_target_name)
245  new_target_dicts[fully_qualified_target_name] = sources_target
246  new_data_target = {}
247  new_data_target['target_name'] = sources_target['target_name']
248  new_data_target['_DEPTH'] = depth
249  new_data_target['toolset'] = "target"
250  new_data[sources_gyp] = {}
251  new_data[sources_gyp]['targets'] = []
252  new_data[sources_gyp]['included_files'] = []
253  new_data[sources_gyp]['xcode_settings'] = \
254      data[orig_gyp].get('xcode_settings', {})
255  new_data[sources_gyp]['targets'].append(new_data_target)
256
257  # Write workspace to file.
258  _WriteWorkspace(main_gyp, sources_gyp)
259  return (new_target_list, new_target_dicts, new_data)
260