1#!/usr/bin/env python
2# Copyright (c) 2012 Google Inc. 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
6"""Utility functions to perform Xcode-style build steps.
7
8These functions are executed via gyp-mac-tool when using the Makefile generator.
9"""
10
11from __future__ import print_function
12
13import fcntl
14import fnmatch
15import glob
16import json
17import os
18import plistlib
19import re
20import shutil
21import struct
22import subprocess
23import sys
24import tempfile
25
26
27def main(args):
28  executor = MacTool()
29  exit_code = executor.Dispatch(args)
30  if exit_code is not None:
31    sys.exit(exit_code)
32
33
34class MacTool(object):
35  """This class performs all the Mac tooling steps. The methods can either be
36  executed directly, or dispatched from an argument list."""
37
38  def Dispatch(self, args):
39    """Dispatches a string command to a method."""
40    if len(args) < 1:
41      raise Exception("Not enough arguments")
42
43    method = "Exec%s" % self._CommandifyName(args[0])
44    return getattr(self, method)(*args[1:])
45
46  def _CommandifyName(self, name_string):
47    """Transforms a tool name like copy-info-plist to CopyInfoPlist"""
48    return name_string.title().replace('-', '')
49
50  def ExecCopyBundleResource(self, source, dest, convert_to_binary):
51    """Copies a resource file to the bundle/Resources directory, performing any
52    necessary compilation on each resource."""
53    convert_to_binary = convert_to_binary == 'True'
54    extension = os.path.splitext(source)[1].lower()
55    if os.path.isdir(source):
56      # Copy tree.
57      # TODO(thakis): This copies file attributes like mtime, while the
58      # single-file branch below doesn't. This should probably be changed to
59      # be consistent with the single-file branch.
60      if os.path.exists(dest):
61        shutil.rmtree(dest)
62      shutil.copytree(source, dest)
63    elif extension == '.xib':
64      return self._CopyXIBFile(source, dest)
65    elif extension == '.storyboard':
66      return self._CopyXIBFile(source, dest)
67    elif extension == '.strings' and not convert_to_binary:
68      self._CopyStringsFile(source, dest)
69    else:
70      if os.path.exists(dest):
71        os.unlink(dest)
72      shutil.copy(source, dest)
73
74    if convert_to_binary and extension in ('.plist', '.strings'):
75      self._ConvertToBinary(dest)
76
77  def _CopyXIBFile(self, source, dest):
78    """Compiles a XIB file with ibtool into a binary plist in the bundle."""
79
80    # ibtool sometimes crashes with relative paths. See crbug.com/314728.
81    base = os.path.dirname(os.path.realpath(__file__))
82    if os.path.relpath(source):
83      source = os.path.join(base, source)
84    if os.path.relpath(dest):
85      dest = os.path.join(base, dest)
86
87    args = ['xcrun', 'ibtool', '--errors', '--warnings', '--notices']
88
89    if os.environ['XCODE_VERSION_ACTUAL'] > '0700':
90      args.extend(['--auto-activate-custom-fonts'])
91      if 'IPHONEOS_DEPLOYMENT_TARGET' in os.environ:
92        args.extend([
93            '--target-device', 'iphone', '--target-device', 'ipad',
94            '--minimum-deployment-target',
95            os.environ['IPHONEOS_DEPLOYMENT_TARGET'],
96        ])
97      else:
98        args.extend([
99            '--target-device', 'mac',
100            '--minimum-deployment-target',
101            os.environ['MACOSX_DEPLOYMENT_TARGET'],
102        ])
103
104    args.extend(['--output-format', 'human-readable-text', '--compile', dest,
105        source])
106
107    ibtool_section_re = re.compile(r'/\*.*\*/')
108    ibtool_re = re.compile(r'.*note:.*is clipping its content')
109    try:
110      stdout = subprocess.check_output(args)
111    except subprocess.CalledProcessError as e:
112      print(e.output)
113      raise
114    current_section_header = None
115    for line in stdout.splitlines():
116      line_decoded = line.decode('utf-8')
117      if ibtool_section_re.match(line_decoded):
118        current_section_header = line_decoded
119      elif not ibtool_re.match(line_decoded):
120        if current_section_header:
121          print(current_section_header)
122          current_section_header = None
123        print(line_decoded)
124    return 0
125
126  def _ConvertToBinary(self, dest):
127    subprocess.check_call([
128        'xcrun', 'plutil', '-convert', 'binary1', '-o', dest, dest])
129
130  def _CopyStringsFile(self, source, dest):
131    """Copies a .strings file using iconv to reconvert the input into UTF-16."""
132    input_code = self._DetectInputEncoding(source) or "UTF-8"
133
134    # Xcode's CpyCopyStringsFile / builtin-copyStrings seems to call
135    # CFPropertyListCreateFromXMLData() behind the scenes; at least it prints
136    #     CFPropertyListCreateFromXMLData(): Old-style plist parser: missing
137    #     semicolon in dictionary.
138    # on invalid files. Do the same kind of validation.
139    import CoreFoundation
140    s = open(source, 'rb').read()
141    d = CoreFoundation.CFDataCreate(None, s, len(s))
142    _, error = CoreFoundation.CFPropertyListCreateFromXMLData(None, d, 0, None)
143    if error:
144      return
145
146    fp = open(dest, 'wb')
147    fp.write(s.decode(input_code).encode('UTF-16'))
148    fp.close()
149
150  def _DetectInputEncoding(self, file_name):
151    """Reads the first few bytes from file_name and tries to guess the text
152    encoding. Returns None as a guess if it can't detect it."""
153    fp = open(file_name, 'rb')
154    try:
155      header = fp.read(3)
156    except:
157      fp.close()
158      return None
159    fp.close()
160    if header.startswith(b"\xFE\xFF"):
161      return "UTF-16"
162    elif header.startswith(b"\xFF\xFE"):
163      return "UTF-16"
164    elif header.startswith(b"\xEF\xBB\xBF"):
165      return "UTF-8"
166    else:
167      return None
168
169  def ExecCopyInfoPlist(self, source, dest, convert_to_binary, *keys):
170    """Copies the |source| Info.plist to the destination directory |dest|."""
171    # Read the source Info.plist into memory.
172    fd = open(source, 'r')
173    lines = fd.read()
174    fd.close()
175
176    # Insert synthesized key/value pairs (e.g. BuildMachineOSBuild).
177    plist = plistlib.readPlistFromString(lines)
178    if keys:
179      plist.update(json.loads(keys[0]))
180    lines = plistlib.writePlistToString(plist)
181
182    # Go through all the environment variables and replace them as variables in
183    # the file.
184    IDENT_RE = re.compile(r'[_/\s]')
185    for key in os.environ:
186      if key.startswith('_'):
187        continue
188      evar = '${%s}' % key
189      evalue = os.environ[key]
190      lines = lines.replace(evar, evalue)
191
192      # Xcode supports various suffices on environment variables, which are
193      # all undocumented. :rfc1034identifier is used in the standard project
194      # template these days, and :identifier was used earlier. They are used to
195      # convert non-url characters into things that look like valid urls --
196      # except that the replacement character for :identifier, '_' isn't valid
197      # in a URL either -- oops, hence :rfc1034identifier was born.
198      evar = '${%s:identifier}' % key
199      evalue = IDENT_RE.sub('_', os.environ[key])
200      lines = lines.replace(evar, evalue)
201
202      evar = '${%s:rfc1034identifier}' % key
203      evalue = IDENT_RE.sub('-', os.environ[key])
204      lines = lines.replace(evar, evalue)
205
206    # Remove any keys with values that haven't been replaced.
207    lines = lines.split('\n')
208    for i in range(len(lines)):
209      if lines[i].strip().startswith("<string>${"):
210        lines[i] = None
211        lines[i - 1] = None
212    lines = '\n'.join(filter(lambda x: x is not None, lines))
213
214    # Write out the file with variables replaced.
215    fd = open(dest, 'w')
216    fd.write(lines)
217    fd.close()
218
219    # Now write out PkgInfo file now that the Info.plist file has been
220    # "compiled".
221    self._WritePkgInfo(dest)
222
223    if convert_to_binary == 'True':
224      self._ConvertToBinary(dest)
225
226  def _WritePkgInfo(self, info_plist):
227    """This writes the PkgInfo file from the data stored in Info.plist."""
228    plist = plistlib.readPlist(info_plist)
229    if not plist:
230      return
231
232    # Only create PkgInfo for executable types.
233    package_type = plist['CFBundlePackageType']
234    if package_type != 'APPL':
235      return
236
237    # The format of PkgInfo is eight characters, representing the bundle type
238    # and bundle signature, each four characters. If that is missing, four
239    # '?' characters are used instead.
240    signature_code = plist.get('CFBundleSignature', '????')
241    if len(signature_code) != 4:  # Wrong length resets everything, too.
242      signature_code = '?' * 4
243
244    dest = os.path.join(os.path.dirname(info_plist), 'PkgInfo')
245    fp = open(dest, 'w')
246    fp.write('%s%s' % (package_type, signature_code))
247    fp.close()
248
249  def ExecFlock(self, lockfile, *cmd_list):
250    """Emulates the most basic behavior of Linux's flock(1)."""
251    # Rely on exception handling to report errors.
252    fd = os.open(lockfile, os.O_RDONLY|os.O_NOCTTY|os.O_CREAT, 0o666)
253    fcntl.flock(fd, fcntl.LOCK_EX)
254    return subprocess.call(cmd_list)
255
256  def ExecFilterLibtool(self, *cmd_list):
257    """Calls libtool and filters out '/path/to/libtool: file: foo.o has no
258    symbols'."""
259    libtool_re = re.compile(r'^.*libtool: (?:for architecture: \S* )?'
260                            r'file: .* has no symbols$')
261    libtool_re5 = re.compile(
262        r'^.*libtool: warning for library: ' +
263        r'.* the table of contents is empty ' +
264        r'\(no object file members in the library define global symbols\)$')
265    env = os.environ.copy()
266    # Ref:
267    # http://www.opensource.apple.com/source/cctools/cctools-809/misc/libtool.c
268    # The problem with this flag is that it resets the file mtime on the file to
269    # epoch=0, e.g. 1970-1-1 or 1969-12-31 depending on timezone.
270    env['ZERO_AR_DATE'] = '1'
271    libtoolout = subprocess.Popen(cmd_list, stderr=subprocess.PIPE, env=env)
272    _, err = libtoolout.communicate()
273    for line in err.splitlines():
274      line_decoded = line.decode('utf-8')
275      if not libtool_re.match(line_decoded) and not libtool_re5.match(line_decoded):
276        print(line_decoded, file=sys.stderr)
277    # Unconditionally touch the output .a file on the command line if present
278    # and the command succeeded. A bit hacky.
279    if not libtoolout.returncode:
280      for i in range(len(cmd_list) - 1):
281        if cmd_list[i] == "-o" and cmd_list[i+1].endswith('.a'):
282          os.utime(cmd_list[i+1], None)
283          break
284    return libtoolout.returncode
285
286  def ExecPackageIosFramework(self, framework):
287    # Find the name of the binary based on the part before the ".framework".
288    binary = os.path.basename(framework).split('.')[0]
289    module_path = os.path.join(framework, 'Modules');
290    if not os.path.exists(module_path):
291      os.mkdir(module_path)
292    module_template = 'framework module %s {\n' \
293                      '  umbrella header "%s.h"\n' \
294                      '\n' \
295                      '  export *\n' \
296                      '  module * { export * }\n' \
297                      '}\n' % (binary, binary)
298
299    module_file = open(os.path.join(module_path, 'module.modulemap'), "w")
300    module_file.write(module_template)
301    module_file.close()
302
303  def ExecPackageFramework(self, framework, version):
304    """Takes a path to Something.framework and the Current version of that and
305    sets up all the symlinks."""
306    # Find the name of the binary based on the part before the ".framework".
307    binary = os.path.basename(framework).split('.')[0]
308
309    CURRENT = 'Current'
310    RESOURCES = 'Resources'
311    VERSIONS = 'Versions'
312
313    if not os.path.exists(os.path.join(framework, VERSIONS, version, binary)):
314      # Binary-less frameworks don't seem to contain symlinks (see e.g.
315      # chromium's out/Debug/org.chromium.Chromium.manifest/ bundle).
316      return
317
318    # Move into the framework directory to set the symlinks correctly.
319    pwd = os.getcwd()
320    os.chdir(framework)
321
322    # Set up the Current version.
323    self._Relink(version, os.path.join(VERSIONS, CURRENT))
324
325    # Set up the root symlinks.
326    self._Relink(os.path.join(VERSIONS, CURRENT, binary), binary)
327    self._Relink(os.path.join(VERSIONS, CURRENT, RESOURCES), RESOURCES)
328
329    # Back to where we were before!
330    os.chdir(pwd)
331
332  def _Relink(self, dest, link):
333    """Creates a symlink to |dest| named |link|. If |link| already exists,
334    it is overwritten."""
335    if os.path.lexists(link):
336      os.remove(link)
337    os.symlink(dest, link)
338
339  def ExecCompileIosFrameworkHeaderMap(self, out, framework, *all_headers):
340    framework_name = os.path.basename(framework).split('.')[0]
341    all_headers = map(os.path.abspath, all_headers)
342    filelist = {}
343    for header in all_headers:
344      filename = os.path.basename(header)
345      filelist[filename] = header
346      filelist[os.path.join(framework_name, filename)] = header
347    WriteHmap(out, filelist)
348
349  def ExecCopyIosFrameworkHeaders(self, framework, *copy_headers):
350    header_path = os.path.join(framework, 'Headers');
351    if not os.path.exists(header_path):
352      os.makedirs(header_path)
353    for header in copy_headers:
354      shutil.copy(header, os.path.join(header_path, os.path.basename(header)))
355
356  def ExecCompileXcassets(self, keys, *inputs):
357    """Compiles multiple .xcassets files into a single .car file.
358
359    This invokes 'actool' to compile all the inputs .xcassets files. The
360    |keys| arguments is a json-encoded dictionary of extra arguments to
361    pass to 'actool' when the asset catalogs contains an application icon
362    or a launch image.
363
364    Note that 'actool' does not create the Assets.car file if the asset
365    catalogs does not contains imageset.
366    """
367    command_line = [
368      'xcrun', 'actool', '--output-format', 'human-readable-text',
369      '--compress-pngs', '--notices', '--warnings', '--errors',
370    ]
371    is_iphone_target = 'IPHONEOS_DEPLOYMENT_TARGET' in os.environ
372    if is_iphone_target:
373      platform = os.environ['CONFIGURATION'].split('-')[-1]
374      if platform not in ('iphoneos', 'iphonesimulator'):
375        platform = 'iphonesimulator'
376      command_line.extend([
377          '--platform', platform, '--target-device', 'iphone',
378          '--target-device', 'ipad', '--minimum-deployment-target',
379          os.environ['IPHONEOS_DEPLOYMENT_TARGET'], '--compile',
380          os.path.abspath(os.environ['CONTENTS_FOLDER_PATH']),
381      ])
382    else:
383      command_line.extend([
384          '--platform', 'macosx', '--target-device', 'mac',
385          '--minimum-deployment-target', os.environ['MACOSX_DEPLOYMENT_TARGET'],
386          '--compile',
387          os.path.abspath(os.environ['UNLOCALIZED_RESOURCES_FOLDER_PATH']),
388      ])
389    if keys:
390      keys = json.loads(keys)
391      for key, value in keys.items():
392        arg_name = '--' + key
393        if isinstance(value, bool):
394          if value:
395            command_line.append(arg_name)
396        elif isinstance(value, list):
397          for v in value:
398            command_line.append(arg_name)
399            command_line.append(str(v))
400        else:
401          command_line.append(arg_name)
402          command_line.append(str(value))
403    # Note: actool crashes if inputs path are relative, so use os.path.abspath
404    # to get absolute path name for inputs.
405    command_line.extend(map(os.path.abspath, inputs))
406    subprocess.check_call(command_line)
407
408  def ExecMergeInfoPlist(self, output, *inputs):
409    """Merge multiple .plist files into a single .plist file."""
410    merged_plist = {}
411    for path in inputs:
412      plist = self._LoadPlistMaybeBinary(path)
413      self._MergePlist(merged_plist, plist)
414    plistlib.writePlist(merged_plist, output)
415
416  def ExecCodeSignBundle(self, key, entitlements, provisioning, path, preserve):
417    """Code sign a bundle.
418
419    This function tries to code sign an iOS bundle, following the same
420    algorithm as Xcode:
421      1. pick the provisioning profile that best match the bundle identifier,
422         and copy it into the bundle as embedded.mobileprovision,
423      2. copy Entitlements.plist from user or SDK next to the bundle,
424      3. code sign the bundle.
425    """
426    substitutions, overrides = self._InstallProvisioningProfile(
427        provisioning, self._GetCFBundleIdentifier())
428    entitlements_path = self._InstallEntitlements(
429        entitlements, substitutions, overrides)
430
431    args = ['codesign', '--force', '--sign', key]
432    if preserve == 'True':
433      args.extend(['--deep', '--preserve-metadata=identifier,entitlements'])
434    else:
435      args.extend(['--entitlements', entitlements_path])
436    args.extend(['--timestamp=none', path])
437    subprocess.check_call(args)
438
439  def _InstallProvisioningProfile(self, profile, bundle_identifier):
440    """Installs embedded.mobileprovision into the bundle.
441
442    Args:
443      profile: string, optional, short name of the .mobileprovision file
444        to use, if empty or the file is missing, the best file installed
445        will be used
446      bundle_identifier: string, value of CFBundleIdentifier from Info.plist
447
448    Returns:
449      A tuple containing two dictionary: variables substitutions and values
450      to overrides when generating the entitlements file.
451    """
452    source_path, provisioning_data, team_id = self._FindProvisioningProfile(
453        profile, bundle_identifier)
454    target_path = os.path.join(
455        os.environ['BUILT_PRODUCTS_DIR'],
456        os.environ['CONTENTS_FOLDER_PATH'],
457        'embedded.mobileprovision')
458    shutil.copy2(source_path, target_path)
459    substitutions = self._GetSubstitutions(bundle_identifier, team_id + '.')
460    return substitutions, provisioning_data['Entitlements']
461
462  def _FindProvisioningProfile(self, profile, bundle_identifier):
463    """Finds the .mobileprovision file to use for signing the bundle.
464
465    Checks all the installed provisioning profiles (or if the user specified
466    the PROVISIONING_PROFILE variable, only consult it) and select the most
467    specific that correspond to the bundle identifier.
468
469    Args:
470      profile: string, optional, short name of the .mobileprovision file
471        to use, if empty or the file is missing, the best file installed
472        will be used
473      bundle_identifier: string, value of CFBundleIdentifier from Info.plist
474
475    Returns:
476      A tuple of the path to the selected provisioning profile, the data of
477      the embedded plist in the provisioning profile and the team identifier
478      to use for code signing.
479
480    Raises:
481      SystemExit: if no .mobileprovision can be used to sign the bundle.
482    """
483    profiles_dir = os.path.join(
484        os.environ['HOME'], 'Library', 'MobileDevice', 'Provisioning Profiles')
485    if not os.path.isdir(profiles_dir):
486      print((
487          'cannot find mobile provisioning for %s' % bundle_identifier),
488          file=sys.stderr)
489      sys.exit(1)
490    provisioning_profiles = None
491    if profile:
492      profile_path = os.path.join(profiles_dir, profile + '.mobileprovision')
493      if os.path.exists(profile_path):
494        provisioning_profiles = [profile_path]
495    if not provisioning_profiles:
496      provisioning_profiles = glob.glob(
497          os.path.join(profiles_dir, '*.mobileprovision'))
498    valid_provisioning_profiles = {}
499    for profile_path in provisioning_profiles:
500      profile_data = self._LoadProvisioningProfile(profile_path)
501      app_id_pattern = profile_data.get(
502          'Entitlements', {}).get('application-identifier', '')
503      for team_identifier in profile_data.get('TeamIdentifier', []):
504        app_id = '%s.%s' % (team_identifier, bundle_identifier)
505        if fnmatch.fnmatch(app_id, app_id_pattern):
506          valid_provisioning_profiles[app_id_pattern] = (
507              profile_path, profile_data, team_identifier)
508    if not valid_provisioning_profiles:
509      print((
510          'cannot find mobile provisioning for %s' % bundle_identifier),
511          file=sys.stderr)
512      sys.exit(1)
513    # If the user has multiple provisioning profiles installed that can be
514    # used for ${bundle_identifier}, pick the most specific one (ie. the
515    # provisioning profile whose pattern is the longest).
516    selected_key = max(valid_provisioning_profiles, key=lambda v: len(v))
517    return valid_provisioning_profiles[selected_key]
518
519  def _LoadProvisioningProfile(self, profile_path):
520    """Extracts the plist embedded in a provisioning profile.
521
522    Args:
523      profile_path: string, path to the .mobileprovision file
524
525    Returns:
526      Content of the plist embedded in the provisioning profile as a dictionary.
527    """
528    with tempfile.NamedTemporaryFile() as temp:
529      subprocess.check_call([
530          'security', 'cms', '-D', '-i', profile_path, '-o', temp.name])
531      return self._LoadPlistMaybeBinary(temp.name)
532
533  def _MergePlist(self, merged_plist, plist):
534    """Merge |plist| into |merged_plist|."""
535    for key, value in plist.items():
536      if isinstance(value, dict):
537        merged_value = merged_plist.get(key, {})
538        if isinstance(merged_value, dict):
539          self._MergePlist(merged_value, value)
540          merged_plist[key] = merged_value
541        else:
542          merged_plist[key] = value
543      else:
544        merged_plist[key] = value
545
546  def _LoadPlistMaybeBinary(self, plist_path):
547    """Loads into a memory a plist possibly encoded in binary format.
548
549    This is a wrapper around plistlib.readPlist that tries to convert the
550    plist to the XML format if it can't be parsed (assuming that it is in
551    the binary format).
552
553    Args:
554      plist_path: string, path to a plist file, in XML or binary format
555
556    Returns:
557      Content of the plist as a dictionary.
558    """
559    try:
560      # First, try to read the file using plistlib that only supports XML,
561      # and if an exception is raised, convert a temporary copy to XML and
562      # load that copy.
563      return plistlib.readPlist(plist_path)
564    except:
565      pass
566    with tempfile.NamedTemporaryFile() as temp:
567      shutil.copy2(plist_path, temp.name)
568      subprocess.check_call(['plutil', '-convert', 'xml1', temp.name])
569      return plistlib.readPlist(temp.name)
570
571  def _GetSubstitutions(self, bundle_identifier, app_identifier_prefix):
572    """Constructs a dictionary of variable substitutions for Entitlements.plist.
573
574    Args:
575      bundle_identifier: string, value of CFBundleIdentifier from Info.plist
576      app_identifier_prefix: string, value for AppIdentifierPrefix
577
578    Returns:
579      Dictionary of substitutions to apply when generating Entitlements.plist.
580    """
581    return {
582      'CFBundleIdentifier': bundle_identifier,
583      'AppIdentifierPrefix': app_identifier_prefix,
584    }
585
586  def _GetCFBundleIdentifier(self):
587    """Extracts CFBundleIdentifier value from Info.plist in the bundle.
588
589    Returns:
590      Value of CFBundleIdentifier in the Info.plist located in the bundle.
591    """
592    info_plist_path = os.path.join(
593        os.environ['TARGET_BUILD_DIR'],
594        os.environ['INFOPLIST_PATH'])
595    info_plist_data = self._LoadPlistMaybeBinary(info_plist_path)
596    return info_plist_data['CFBundleIdentifier']
597
598  def _InstallEntitlements(self, entitlements, substitutions, overrides):
599    """Generates and install the ${BundleName}.xcent entitlements file.
600
601    Expands variables "$(variable)" pattern in the source entitlements file,
602    add extra entitlements defined in the .mobileprovision file and the copy
603    the generated plist to "${BundlePath}.xcent".
604
605    Args:
606      entitlements: string, optional, path to the Entitlements.plist template
607        to use, defaults to "${SDKROOT}/Entitlements.plist"
608      substitutions: dictionary, variable substitutions
609      overrides: dictionary, values to add to the entitlements
610
611    Returns:
612      Path to the generated entitlements file.
613    """
614    source_path = entitlements
615    target_path = os.path.join(
616        os.environ['BUILT_PRODUCTS_DIR'],
617        os.environ['PRODUCT_NAME'] + '.xcent')
618    if not source_path:
619      source_path = os.path.join(
620          os.environ['SDKROOT'],
621          'Entitlements.plist')
622    shutil.copy2(source_path, target_path)
623    data = self._LoadPlistMaybeBinary(target_path)
624    data = self._ExpandVariables(data, substitutions)
625    if overrides:
626      for key in overrides:
627        if key not in data:
628          data[key] = overrides[key]
629    plistlib.writePlist(data, target_path)
630    return target_path
631
632  def _ExpandVariables(self, data, substitutions):
633    """Expands variables "$(variable)" in data.
634
635    Args:
636      data: object, can be either string, list or dictionary
637      substitutions: dictionary, variable substitutions to perform
638
639    Returns:
640      Copy of data where each references to "$(variable)" has been replaced
641      by the corresponding value found in substitutions, or left intact if
642      the key was not found.
643    """
644    if isinstance(data, str):
645      for key, value in substitutions.items():
646        data = data.replace('$(%s)' % key, value)
647      return data
648    if isinstance(data, list):
649      return [self._ExpandVariables(v, substitutions) for v in data]
650    if isinstance(data, dict):
651      return {k: self._ExpandVariables(data[k], substitutions) for k in data}
652    return data
653
654def NextGreaterPowerOf2(x):
655  return 2**(x).bit_length()
656
657def WriteHmap(output_name, filelist):
658  """Generates a header map based on |filelist|.
659
660  Per Mark Mentovai:
661    A header map is structured essentially as a hash table, keyed by names used
662    in #includes, and providing pathnames to the actual files.
663
664  The implementation below and the comment above comes from inspecting:
665    http://www.opensource.apple.com/source/distcc/distcc-2503/distcc_dist/include_server/headermap.py?txt
666  while also looking at the implementation in clang in:
667    https://llvm.org/svn/llvm-project/cfe/trunk/lib/Lex/HeaderMap.cpp
668  """
669  magic = 1751998832
670  version = 1
671  _reserved = 0
672  count = len(filelist)
673  capacity = NextGreaterPowerOf2(count)
674  strings_offset = 24 + (12 * capacity)
675  max_value_length = len(max(filelist.items(), key=lambda t: len(t[1]))[1])
676
677  out = open(output_name, "wb")
678  out.write(struct.pack('<LHHLLLL', magic, version, _reserved, strings_offset,
679                        count, capacity, max_value_length))
680
681  # Create empty hashmap buckets.
682  buckets = [None] * capacity
683  for file, path in filelist.items():
684    key = 0
685    for c in file:
686      key += ord(c.lower()) * 13
687
688    # Fill next empty bucket.
689    while buckets[key & capacity - 1] is not None:
690      key = key + 1
691    buckets[key & capacity - 1] = (file, path)
692
693  next_offset = 1
694  for bucket in buckets:
695    if bucket is None:
696      out.write(struct.pack('<LLL', 0, 0, 0))
697    else:
698      (file, path) = bucket
699      key_offset = next_offset
700      prefix_offset = key_offset + len(file) + 1
701      suffix_offset = prefix_offset + len(os.path.dirname(path) + os.sep) + 1
702      next_offset = suffix_offset + len(os.path.basename(path)) + 1
703      out.write(struct.pack('<LLL', key_offset, prefix_offset, suffix_offset))
704
705  # Pad byte since next offset starts at 1.
706  out.write(struct.pack('<x'))
707
708  for bucket in buckets:
709    if bucket is not None:
710      (file, path) = bucket
711      out.write(struct.pack('<%ds' % len(file), file))
712      out.write(struct.pack('<s', '\0'))
713      base = os.path.dirname(path) + os.sep
714      out.write(struct.pack('<%ds' % len(base), base))
715      out.write(struct.pack('<s', '\0'))
716      path = os.path.basename(path)
717      out.write(struct.pack('<%ds' % len(path), path))
718      out.write(struct.pack('<s', '\0'))
719
720if __name__ == '__main__':
721  sys.exit(main(sys.argv[1:]))
722