1# Copyright 2023 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5import argparse 6import collections 7import contextlib 8import hashlib 9import io 10import json 11import multiprocessing 12import os 13import re 14import shutil 15import subprocess 16import sys 17import tempfile 18 19 20class ArgumentForwarder(object): 21 """Class used to abstract forwarding arguments from to the swiftc compiler. 22 23 Arguments: 24 - arg_name: string corresponding to the argument to pass to the compiler 25 - arg_join: function taking the compiler name and returning whether the 26 argument value is attached to the argument or separated 27 - to_swift: function taking the argument value and returning whether it 28 must be passed to the swift compiler 29 - to_clang: function taking the argument value and returning whether it 30 must be passed to the clang compiler 31 """ 32 33 def __init__(self, arg_name, arg_join, to_swift, to_clang): 34 self._arg_name = arg_name 35 self._arg_join = arg_join 36 self._to_swift = to_swift 37 self._to_clang = to_clang 38 39 def forward(self, swiftc_args, values, target_triple): 40 if not values: 41 return 42 43 is_catalyst = target_triple.endswith('macabi') 44 for value in values: 45 if self._to_swift(value): 46 if self._arg_join('swift'): 47 swiftc_args.append(f'{self._arg_name}{value}') 48 else: 49 swiftc_args.append(self._arg_name) 50 swiftc_args.append(value) 51 52 if self._to_clang(value) and not is_catalyst: 53 if self._arg_join('clang'): 54 swiftc_args.append('-Xcc') 55 swiftc_args.append(f'{self._arg_name}{value}') 56 else: 57 swiftc_args.append('-Xcc') 58 swiftc_args.append(self._arg_name) 59 swiftc_args.append('-Xcc') 60 swiftc_args.append(value) 61 62 63class IncludeArgumentForwarder(ArgumentForwarder): 64 """Argument forwarder for -I and -isystem.""" 65 66 def __init__(self, arg_name): 67 ArgumentForwarder.__init__(self, 68 arg_name, 69 arg_join=lambda _: len(arg_name) == 1, 70 to_swift=lambda _: arg_name != '-isystem', 71 to_clang=lambda _: True) 72 73 74class FrameworkArgumentForwarder(ArgumentForwarder): 75 """Argument forwarder for -F and -Fsystem.""" 76 77 def __init__(self, arg_name): 78 ArgumentForwarder.__init__(self, 79 arg_name, 80 arg_join=lambda _: len(arg_name) == 1, 81 to_swift=lambda _: True, 82 to_clang=lambda _: True) 83 84 85class DefineArgumentForwarder(ArgumentForwarder): 86 """Argument forwarder for -D.""" 87 88 def __init__(self, arg_name): 89 ArgumentForwarder.__init__(self, 90 arg_name, 91 arg_join=lambda _: _ == 'clang', 92 to_swift=lambda _: '=' not in _, 93 to_clang=lambda _: True) 94 95 96# Dictionary mapping argument names to their ArgumentForwarder. 97ARGUMENT_FORWARDER_FOR_ATTR = ( 98 ('include_dirs', IncludeArgumentForwarder('-I')), 99 ('system_include_dirs', IncludeArgumentForwarder('-isystem')), 100 ('framework_dirs', FrameworkArgumentForwarder('-F')), 101 ('system_framework_dirs', FrameworkArgumentForwarder('-Fsystem')), 102 ('defines', DefineArgumentForwarder('-D')), 103) 104 105# Regexp used to parse #import lines. 106IMPORT_LINE_REGEXP = re.compile('#import "([^"]*)"') 107 108 109class FileWriter(contextlib.AbstractContextManager): 110 """ 111 FileWriter is a file-like object that only write data to disk if changed. 112 113 This object implements the context manager protocols and thus can be used 114 in a with-clause. The data is written to disk when the context is exited, 115 and only if the content is different from current file content. 116 117 with FileWriter(path) as stream: 118 stream.write('...') 119 120 If the with-clause ends with an exception, no data is written to the disk 121 and any existing file is left untouched. 122 """ 123 124 def __init__(self, filepath, encoding='utf8'): 125 self._stringio = io.StringIO() 126 self._filepath = filepath 127 self._encoding = encoding 128 129 def __exit__(self, exc_type, exc_value, traceback): 130 if exc_type or exc_value or traceback: 131 return 132 133 new_content = self._stringio.getvalue() 134 if os.path.exists(self._filepath): 135 with open(self._filepath, encoding=self._encoding) as stream: 136 old_content = stream.read() 137 138 if old_content == new_content: 139 return 140 141 with open(self._filepath, 'w', encoding=self._encoding) as stream: 142 stream.write(new_content) 143 144 def write(self, data): 145 self._stringio.write(data) 146 147 148@contextlib.contextmanager 149def existing_directory(path): 150 """Returns a context manager wrapping an existing directory.""" 151 yield path 152 153 154def create_stamp_file(path): 155 """Writes an empty stamp file at path.""" 156 with FileWriter(path) as stream: 157 stream.write('') 158 159 160def create_build_cache_dir(args, build_signature): 161 """Creates the build cache directory according to `args`. 162 163 This function returns an object that implements the context manager 164 protocol and thus can be used in a with-clause. If -derived-data-dir 165 argument is not used, the returned directory is a temporary directory 166 that will be deleted when the with-clause is exited. 167 """ 168 if not args.derived_data_dir: 169 return tempfile.TemporaryDirectory() 170 171 # The derived data cache can be quite large, so delete any obsolete 172 # files or directories. 173 stamp_name = f'{args.module_name}.stamp' 174 if os.path.isdir(args.derived_data_dir): 175 for name in os.listdir(args.derived_data_dir): 176 if name not in (build_signature, stamp_name): 177 path = os.path.join(args.derived_data_dir, name) 178 if os.path.isdir(path): 179 shutil.rmtree(path) 180 else: 181 os.unlink(path) 182 183 ensure_directory(args.derived_data_dir) 184 create_stamp_file(os.path.join(args.derived_data_dir, stamp_name)) 185 186 return existing_directory( 187 ensure_directory(os.path.join(args.derived_data_dir, build_signature))) 188 189 190def ensure_directory(path): 191 """Creates directory at `path` if it does not exists.""" 192 if not os.path.isdir(path): 193 os.makedirs(path) 194 return path 195 196 197def build_signature(env, args): 198 """Generates the build signature from `env` and `args`. 199 200 This allow re-using the derived data dir between builds while still 201 forcing the data to be recreated from scratch in case of significant 202 changes to the build settings (different arguments or tool versions). 203 """ 204 m = hashlib.sha1() 205 for key in sorted(env): 206 if key.endswith('_VERSION') or key == 'DEVELOPER_DIR': 207 m.update(f'{key}={env[key]}'.encode('utf8')) 208 for i, arg in enumerate(args): 209 m.update(f'{i}={arg}'.encode('utf8')) 210 return m.hexdigest() 211 212 213def generate_source_output_file_map_fragment(args, filename): 214 """Generates source OutputFileMap.json fragment according to `args`. 215 216 Create the fragment for a single .swift source file for OutputFileMap. 217 The output depends on whether -whole-module-optimization argument is 218 used or not. 219 """ 220 assert os.path.splitext(filename)[1] == '.swift', filename 221 basename = os.path.splitext(os.path.basename(filename))[0] 222 rel_name = os.path.join(args.target_out_dir, basename) 223 out_name = rel_name 224 225 fragment = { 226 'index-unit-output-path': f'/{rel_name}.o', 227 'object': f'{out_name}.o', 228 } 229 230 if not args.whole_module_optimization: 231 fragment.update({ 232 'const-values': f'{out_name}.swiftconstvalues', 233 'dependencies': f'{out_name}.d', 234 'diagnostics': f'{out_name}.dia', 235 'swift-dependencies': f'{out_name}.swiftdeps', 236 }) 237 238 return fragment 239 240 241def generate_module_output_file_map_fragment(args): 242 """Generates module OutputFileMap.json fragment according to `args`. 243 244 Create the fragment for the module itself for OutputFileMap. The output 245 depends on whether -whole-module-optimization argument is used or not. 246 """ 247 out_name = os.path.join(args.target_out_dir, args.module_name) 248 249 if args.whole_module_optimization: 250 fragment = { 251 'const-values': f'{out_name}.swiftconstvalues', 252 'dependencies': f'{out_name}.d', 253 'diagnostics': f'{out_name}.dia', 254 'swift-dependencies': f'{out_name}.swiftdeps', 255 } 256 else: 257 fragment = { 258 'emit-module-dependencies': f'{out_name}.d', 259 'emit-module-diagnostics': f'{out_name}.dia', 260 'swift-dependencies': f'{out_name}.swiftdeps', 261 } 262 263 return fragment 264 265 266def generate_output_file_map(args): 267 """Generates OutputFileMap.json according to `args`. 268 269 Returns the mapping as a python dictionary that can be serialized to 270 disk as JSON. 271 """ 272 output_file_map = {'': generate_module_output_file_map_fragment(args)} 273 for filename in args.sources: 274 fragment = generate_source_output_file_map_fragment(args, filename) 275 output_file_map[filename] = fragment 276 return output_file_map 277 278 279def fix_generated_header(header_path, output_path, src_dir, gen_dir): 280 """Fix the Objective-C header generated by the Swift compiler. 281 282 The Swift compiler assumes that the generated Objective-C header will be 283 imported from code compiled with module support enabled (-fmodules). The 284 generated code thus uses @import and provides no fallback if modules are 285 not enabled. 286 287 The Swift compiler also uses absolute path when including the bridging 288 header or another module's generated header. This causes issues with the 289 distributed compiler (i.e. reclient or siso) who expects all paths to be 290 relative to the build directory 291 292 This method fix the generated header to use relative path for #import 293 and to use #import instead of @import when using system frameworks. 294 295 The header is read at `header_path` and written to `output_path`. 296 """ 297 298 header_contents = [] 299 with open(header_path, 'r', encoding='utf8') as header_file: 300 301 imports_section = None 302 for line in header_file: 303 # Handle #import lines. 304 match = IMPORT_LINE_REGEXP.match(line) 305 if match: 306 import_path = match.group(1) 307 for root in (gen_dir, src_dir): 308 if import_path.startswith(root): 309 import_path = os.path.relpath(import_path, root) 310 if import_path != match.group(1): 311 span = match.span(1) 312 line = line[:span[0]] + import_path + line[span[1]:] 313 314 # Handle @import lines. 315 if line.startswith('#if __has_feature(objc_modules)'): 316 assert imports_section is None 317 imports_section = (len(header_contents) + 1, 1) 318 elif imports_section: 319 section_start, nesting_level = imports_section 320 if line.startswith('#if'): 321 imports_section = (section_start, nesting_level + 1) 322 elif line.startswith('#endif'): 323 if nesting_level > 1: 324 imports_section = (section_start, nesting_level - 1) 325 else: 326 imports_section = None 327 section_end = len(header_contents) 328 header_contents.append('#else\n') 329 for index in range(section_start, section_end): 330 l = header_contents[index] 331 if l.startswith('@import'): 332 name = l.split()[1].split(';')[0] 333 if name != 'ObjectiveC': 334 header_contents.append(f'#import <{name}/{name}.h>\n') 335 else: 336 header_contents.append(l) 337 338 header_contents.append(line) 339 340 with FileWriter(output_path) as header_file: 341 for line in header_contents: 342 header_file.write(line) 343 344 345def invoke_swift_compiler(args, extras_args, build_cache_dir, output_file_map): 346 """Invokes Swift compiler to compile module according to `args`. 347 348 The `build_cache_dir` and `output_file_map` should be path to existing 349 directory to use for writing intermediate build artifact (optionally 350 a temporary directory) and path to $module-OutputFileMap.json file that 351 lists the outputs to generate for the module and each source file. 352 353 If -fix-module-imports argument is passed, the generated header for the 354 module is written to a temporary location and then modified to replace 355 @import by corresponding #import. 356 """ 357 358 # Write the $module.SwiftFileList file. 359 swift_file_list_path = os.path.join(args.target_out_dir, 360 f'{args.module_name}.SwiftFileList') 361 362 with FileWriter(swift_file_list_path) as stream: 363 for filename in sorted(args.sources): 364 stream.write(f'"{filename}"\n') 365 366 header_path = args.header_path 367 if args.fix_generated_header: 368 header_path = os.path.join(build_cache_dir, os.path.basename(header_path)) 369 370 swiftc_args = [ 371 '-parse-as-library', 372 '-module-name', 373 args.module_name, 374 f'@{swift_file_list_path}', 375 '-sdk', 376 args.sdk_path, 377 '-target', 378 args.target_triple, 379 '-swift-version', 380 args.swift_version, 381 '-c', 382 '-output-file-map', 383 output_file_map, 384 '-save-temps', 385 '-no-color-diagnostics', 386 '-serialize-diagnostics', 387 '-emit-dependencies', 388 '-emit-module', 389 '-emit-module-path', 390 os.path.join(args.target_out_dir, f'{args.module_name}.swiftmodule'), 391 '-emit-objc-header', 392 '-emit-objc-header-path', 393 header_path, 394 '-working-directory', 395 os.getcwd(), 396 '-index-store-path', 397 ensure_directory(os.path.join(build_cache_dir, 'Index.noindex')), 398 '-module-cache-path', 399 ensure_directory(os.path.join(build_cache_dir, 'ModuleCache.noindex')), 400 '-pch-output-dir', 401 ensure_directory(os.path.join(build_cache_dir, 'PrecompiledHeaders')), 402 ] 403 404 # Handle optional -bridge-header flag. 405 if args.bridge_header: 406 swiftc_args.extend(('-import-objc-header', args.bridge_header)) 407 408 # Handle swift const values extraction. 409 swiftc_args.extend(['-emit-const-values']) 410 swiftc_args.extend([ 411 '-Xfrontend', 412 '-const-gather-protocols-file', 413 '-Xfrontend', 414 args.const_gather_protocols_file, 415 ]) 416 417 # Handle -I, -F, -isystem, -Fsystem and -D arguments. 418 for (attr_name, forwarder) in ARGUMENT_FORWARDER_FOR_ATTR: 419 forwarder.forward(swiftc_args, getattr(args, attr_name), args.target_triple) 420 421 # Handle -whole-module-optimization flag. 422 num_threads = max(1, multiprocessing.cpu_count() // 2) 423 if args.whole_module_optimization: 424 swiftc_args.extend([ 425 '-whole-module-optimization', 426 '-no-emit-module-separately-wmo', 427 '-num-threads', 428 f'{num_threads}', 429 ]) 430 else: 431 swiftc_args.extend([ 432 '-enable-batch-mode', 433 '-incremental', 434 '-experimental-emit-module-separately', 435 '-disable-cmo', 436 f'-j{num_threads}', 437 ]) 438 439 # Handle -file-prefix-map flag. 440 if args.file_prefix_map: 441 swiftc_args.extend([ 442 '-file-prefix-map', 443 args.file_prefix_map, 444 ]) 445 446 swift_toolchain_path = args.swift_toolchain_path 447 if not swift_toolchain_path: 448 swift_toolchain_path = os.path.join(os.path.dirname(args.sdk_path), 449 'XcodeDefault.xctoolchain') 450 if not os.path.isdir(swift_toolchain_path): 451 swift_toolchain_path = '' 452 453 command = [f'{swift_toolchain_path}/usr/bin/swiftc'] + swiftc_args 454 if extras_args: 455 command.extend(extras_args) 456 457 process = subprocess.Popen(command) 458 process.communicate() 459 460 if process.returncode: 461 sys.exit(process.returncode) 462 463 if args.fix_generated_header: 464 fix_generated_header(header_path, 465 args.header_path, 466 src_dir=os.path.abspath(args.src_dir) + os.path.sep, 467 gen_dir=os.path.abspath(args.gen_dir) + os.path.sep) 468 469 470def generate_depfile(args, output_file_map): 471 """Generates compilation depfile according to `args`. 472 473 Parses all intermediate depfile generated by the Swift compiler and 474 replaces absolute path by relative paths (since ninja compares paths 475 as strings and does not resolve relative paths to absolute). 476 477 Converts path to the SDK and toolchain files to the sdk/xcode_link 478 symlinks if possible and available. 479 """ 480 xcode_paths = {} 481 if os.path.islink(args.sdk_path): 482 xcode_links = os.path.dirname(args.sdk_path) 483 for link_name in os.listdir(xcode_links): 484 link_path = os.path.join(xcode_links, link_name) 485 if os.path.islink(link_path): 486 xcode_paths[os.path.realpath(link_path) + os.sep] = link_path + os.sep 487 488 out_dir = os.getcwd() + os.path.sep 489 src_dir = os.path.abspath(args.src_dir) + os.path.sep 490 491 depfile_content = collections.defaultdict(set) 492 for value in output_file_map.values(): 493 partial_depfile_path = value.get('dependencies', None) 494 if partial_depfile_path: 495 with open(partial_depfile_path, encoding='utf8') as stream: 496 for line in stream: 497 output, inputs = line.split(' : ', 2) 498 output = os.path.relpath(output, out_dir) 499 500 # The depfile format uses '\' to quote space in filename. Split the 501 # list of file while respecting this convention. 502 for path in re.split(r'(?<!\\) ', inputs): 503 for xcode_path in xcode_paths: 504 if path.startswith(xcode_path): 505 path = xcode_paths[xcode_path] + path[len(xcode_path):] 506 if path.startswith(src_dir) or path.startswith(out_dir): 507 path = os.path.relpath(path, out_dir) 508 depfile_content[output].add(path) 509 510 with FileWriter(args.depfile_path) as stream: 511 for output, inputs in sorted(depfile_content.items()): 512 stream.write(f'{output}: {" ".join(sorted(inputs))}\n') 513 514 515def compile_module(args, extras_args, build_signature): 516 """Compiles Swift module according to `args`.""" 517 for path in (args.target_out_dir, os.path.dirname(args.header_path)): 518 ensure_directory(path) 519 520 # Write the $module-OutputFileMap.json file. 521 output_file_map = generate_output_file_map(args) 522 output_file_map_path = os.path.join(args.target_out_dir, 523 f'{args.module_name}-OutputFileMap.json') 524 525 with FileWriter(output_file_map_path) as stream: 526 json.dump(output_file_map, stream, indent=' ', sort_keys=True) 527 528 # Invoke Swift compiler. 529 with create_build_cache_dir(args, build_signature) as build_cache_dir: 530 invoke_swift_compiler(args, 531 extras_args, 532 build_cache_dir=build_cache_dir, 533 output_file_map=output_file_map_path) 534 535 # Generate the depfile. 536 generate_depfile(args, output_file_map) 537 538 539def main(args): 540 parser = argparse.ArgumentParser(allow_abbrev=False, add_help=False) 541 542 # Required arguments. 543 parser.add_argument('--module-name', 544 required=True, 545 help='name of the Swift module') 546 547 parser.add_argument('--src-dir', 548 required=True, 549 help='path to the source directory') 550 551 parser.add_argument('--gen-dir', 552 required=True, 553 help='path to the gen directory root') 554 555 parser.add_argument('--target-out-dir', 556 required=True, 557 help='path to the object directory') 558 559 parser.add_argument('--header-path', 560 required=True, 561 help='path to the generated header file') 562 563 parser.add_argument('--bridge-header', 564 required=True, 565 help='path to the Objective-C bridge header file') 566 567 parser.add_argument('--depfile-path', 568 required=True, 569 help='path to the output dependency file') 570 571 parser.add_argument('--const-gather-protocols-file', 572 required=True, 573 help='path to file containing const values protocols') 574 575 # Optional arguments. 576 parser.add_argument('--derived-data-dir', 577 help='path to the derived data directory') 578 579 parser.add_argument('--fix-generated-header', 580 default=False, 581 action='store_true', 582 help='fix imports in generated header') 583 584 parser.add_argument('--swift-toolchain-path', 585 default='', 586 help='path to the Swift toolchain to use') 587 588 parser.add_argument('--whole-module-optimization', 589 default=False, 590 action='store_true', 591 help='enable whole module optimisation') 592 593 # Required arguments (forwarded to the Swift compiler). 594 parser.add_argument('-target', 595 required=True, 596 dest='target_triple', 597 help='generate code for the given target') 598 599 parser.add_argument('-sdk', 600 required=True, 601 dest='sdk_path', 602 help='path to the iOS SDK') 603 604 # Optional arguments (forwarded to the Swift compiler). 605 parser.add_argument('-I', 606 action='append', 607 dest='include_dirs', 608 help='add directory to header search path') 609 610 parser.add_argument('-isystem', 611 action='append', 612 dest='system_include_dirs', 613 help='add directory to system header search path') 614 615 parser.add_argument('-F', 616 action='append', 617 dest='framework_dirs', 618 help='add directory to framework search path') 619 620 parser.add_argument('-Fsystem', 621 action='append', 622 dest='system_framework_dirs', 623 help='add directory to system framework search path') 624 625 parser.add_argument('-D', 626 action='append', 627 dest='defines', 628 help='add preprocessor define') 629 630 parser.add_argument('-swift-version', 631 default='5', 632 help='version of the Swift language') 633 634 parser.add_argument( 635 '-file-prefix-map', 636 help='remap source paths in debug, coverage, and index info') 637 638 # Positional arguments. 639 parser.add_argument('sources', 640 nargs='+', 641 help='Swift source files to compile') 642 643 parsed, extras = parser.parse_known_args(args) 644 compile_module(parsed, extras, build_signature(os.environ, args)) 645 646 647if __name__ == '__main__': 648 sys.exit(main(sys.argv[1:])) 649