#!/usr/bin/env python3 ##===----------------------------------------------------------------------===## ## ## This source file is part of the Swift.org open source project ## ## Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors ## Licensed under Apache License v2.0 with Runtime Library Exception ## ## See https://swift.org/LICENSE.txt for license information ## See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors ## ##===----------------------------------------------------------------------===## import argparse import json import os import platform import shutil import subprocess import sys import tempfile from typing import Dict, List # ----------------------------------------------------------------------------- # General utilities def fatal_error(message: str): print(message, file=sys.stderr) raise SystemExit(1) def escapeCmdArg(arg: str) -> str: if '"' in arg or " " in arg: return '"%s"' % arg.replace('"', '\\"') else: return arg def print_cmd(cmd: List[str], additional_env: Dict[str, str]) -> None: env_str = " ".join([f"{key}={escapeCmdArg(str(value))}" for (key, value) in additional_env.items()]) command_str = " ".join([escapeCmdArg(str(arg)) for arg in cmd]) print(f"{env_str} {command_str}") def env_with_additional_env(additional_env: Dict[str, str]) -> Dict[str, str]: env = dict(os.environ) for (key, value) in additional_env.items(): env[key] = str(value) return env def check_call(cmd: List[str], additional_env: Dict[str, str] = {}, verbose: bool = False) -> None: if verbose: print_cmd(cmd=cmd, additional_env=additional_env) subprocess.check_call(cmd, env=env_with_additional_env(additional_env), stderr=subprocess.STDOUT, timeout=60 * 60) def check_output(cmd: List[str], additional_env: Dict[str, str] = {}, capture_stderr: bool = True, verbose: bool = False) -> str: if verbose: print_cmd(cmd=cmd, additional_env=additional_env) if capture_stderr: stderr = subprocess.STDOUT else: stderr = subprocess.DEVNULL return subprocess.check_output(cmd, env=env_with_additional_env(additional_env), stderr=stderr, encoding='utf-8', timeout=60 * 60) # ----------------------------------------------------------------------------- # SwiftPM wrappers def swiftpm_bin_path(swift_exec: str, swiftpm_args: List[str], additional_env: Dict[str, str], verbose: bool = False) -> str: """ Return the path of the directory that contains the binaries produced by this package. """ cmd = [swift_exec, 'build', '--show-bin-path'] + swiftpm_args return check_output(cmd, additional_env=additional_env, capture_stderr=False, verbose=verbose).strip() def get_build_target(swift_exec: str, args: argparse.Namespace, cross_compile: bool = False) -> str: """Returns the target-triple of the current machine or for cross-compilation.""" command = [swift_exec, '-print-target-info'] if cross_compile: cross_compile_json = json.load(open(args.cross_compile_config)) command += ['-target', cross_compile_json["target"]] target_info_json = subprocess.check_output(command, stderr=subprocess.PIPE, universal_newlines=True).strip() args.target_info = json.loads(target_info_json) if '-apple-macosx' in args.target_info["target"]["unversionedTriple"]: return args.target_info["target"]["unversionedTriple"] return args.target_info["target"]["triple"] # ----------------------------------------------------------------------------- # Build SourceKit-LSP def get_swiftpm_options(swift_exec: str, args: argparse.Namespace, suppress_verbose: bool = False) -> List[str]: swiftpm_args: List[str] = [ '--package-path', args.package_path, '--scratch-path', args.build_path, '--configuration', args.configuration, ] if args.multiroot_data_file: swiftpm_args += ['--multiroot-data-file', args.multiroot_data_file] if args.verbose and not suppress_verbose: swiftpm_args += ['--verbose'] if args.sanitize: for san in args.sanitize: swiftpm_args += ['--sanitize=%s' % san] build_target = get_build_target(swift_exec, args, cross_compile=(True if args.cross_compile_config else False)) build_os = build_target.split('-')[2] if build_os.startswith('macosx'): swiftpm_args += [ # Prefer just-built plugins to SDK plugins. # This is a workaround for building fat binaries with Xcode build system being old. '-Xswiftc', '-plugin-path', '-Xswiftc', os.path.join(args.toolchain, 'lib', 'swift', 'host', 'plugins'), ] else: swiftpm_args += [ # Dispatch headers '-Xcxx', '-I', '-Xcxx', os.path.join(args.toolchain, 'lib', 'swift'), # For '-Xcxx', '-I', '-Xcxx', os.path.join(args.toolchain, 'lib', 'swift', 'Block'), ] if args.action == 'install': swiftpm_args += ['--disable-local-rpath'] if '-android' in build_target: swiftpm_args += [ '-Xlinker', '-rpath', '-Xlinker', '$ORIGIN/../lib/swift/android', ] elif '-freebsd' in build_target: # pkg installs packages to /usr/local/include on FreeBSD # Required for SwiftPM to find sqlite swiftpm_args += ['-Xcxx', '-I', '-Xcxx', '/usr/local/include', '-Xswiftc', '-I', '-Xswiftc', '/usr/local/include'] elif not build_os.startswith('macosx'): # Library rpath for swift, dispatch, Foundation, etc. when installing swiftpm_args += [ '-Xlinker', '-rpath', '-Xlinker', '$ORIGIN/../lib/swift/' + build_os, ] if args.cross_compile_host: if build_os.startswith('macosx') and args.cross_compile_host.startswith('macosx-'): swiftpm_args += ["--arch", args.cross_compile_host[7:]] elif args.cross_compile_host.startswith('android-'): print('Cross-compiling for %s' % args.cross_compile_host) swiftpm_args += ['--destination', args.cross_compile_config] else: fatal_error("cannot cross-compile for %s" % args.cross_compile_host) return swiftpm_args def get_swiftpm_environment_variables(swift_exec: str, args: argparse.Namespace) -> Dict[str, str]: """ Return the environment variables that should be used for a 'swift build' or 'swift test' invocation. """ env: Dict[str, str] = { # Set the toolchain used in tests at runtime 'SOURCEKIT_TOOLCHAIN_PATH': args.toolchain, 'INDEXSTOREDB_TOOLCHAIN_BIN_PATH': args.toolchain, 'SWIFT_EXEC': f'{swift_exec}c' } # Use local dependencies (i.e. checked out next sourcekit-lsp). if not args.no_local_deps: env['SWIFTCI_USE_LOCAL_DEPS'] = "1" if args.ninja_bin: env['NINJA_BIN'] = args.ninja_bin if args.sanitize and 'address' in args.sanitize: # Workaround reports in Foundation: https://bugs.swift.org/browse/SR-12551 env['ASAN_OPTIONS'] = 'detect_leaks=false' if args.sanitize and 'undefined' in args.sanitize: supp = os.path.join(args.package_path, 'Utilities', 'ubsan_supressions.supp') env['UBSAN_OPTIONS'] = 'halt_on_error=true,suppressions=%s' % supp if args.sanitize and 'thread' in args.sanitize: env['TSAN_OPTIONS'] = 'halt_on_error=true' if args.action == 'test' and args.skip_long_tests: env['SKIP_LONG_TESTS'] = '1' if args.action == 'install': env['SOURCEKIT_LSP_CI_INSTALL'] = "1" return env def build_single_product(product: str, swift_exec: str, args: argparse.Namespace) -> None: """ Build one product in the package """ swiftpm_args = get_swiftpm_options(swift_exec, args) additional_env = get_swiftpm_environment_variables(swift_exec, args) cmd = [swift_exec, 'build', '--product', product] + swiftpm_args check_call(cmd, additional_env=additional_env, verbose=args.verbose) def run_tests(swift_exec: str, args: argparse.Namespace) -> None: """ Run all tests in the package """ swiftpm_args = get_swiftpm_options(swift_exec, args, suppress_verbose=True) additional_env = get_swiftpm_environment_variables(swift_exec, args) # 'swift test' doesn't print os_log output to the command line. Use the # `NonDarwinLogger` that prints to stderr so we can view the log output in CI test # runs. additional_env['SOURCEKIT_LSP_FORCE_NON_DARWIN_LOGGER'] = '1' # CI doesn't contain any sensitive information. Log everything. additional_env['SOURCEKIT_LSP_LOG_PRIVACY_LEVEL'] = 'sensitive' # Log with the highest log level to simplify debugging of CI failures. additional_env['SOURCEKIT_LSP_LOG_LEVEL'] = 'debug' bin_path = swiftpm_bin_path(swift_exec, swiftpm_args, additional_env=additional_env) tests = os.path.join(bin_path, 'sk-tests') print('Cleaning ' + tests) shutil.rmtree(tests, ignore_errors=True) # Build the plugin so it can be used by the tests. SwiftPM is not able to express a dependency from a test target on # a product. build_single_product('SwiftSourceKitPlugin', swift_exec, args) build_single_product('SwiftSourceKitClientPlugin', swift_exec, args) cmd = [ swift_exec, 'test', '--disable-testable-imports', '--test-product', 'SourceKitLSPPackageTests' ] + swiftpm_args with tempfile.TemporaryDirectory() as test_module_cache: additional_env['SOURCEKIT_LSP_TEST_MODULE_CACHE'] = f"{test_module_cache}/module-cache" # Try running tests in parallel. If that fails, run tests in serial to get capture more readable output. try: check_call(cmd + ['--parallel'], additional_env=additional_env, verbose=args.verbose) except: print('--- Running tests in parallel failed. Re-running tests serially to capture more actionable output.') sys.stdout.flush() check_call(cmd, additional_env=additional_env, verbose=args.verbose) # Return with non-zero exit code even if serial test execution succeeds. raise SystemExit(1) def copy_file(source: str, destination_dir: str, verbose: bool) -> None: """ Copies the file at `source` into `destination_dir`. """ os.makedirs(destination_dir, exist_ok=True) check_call(['rsync', '-a', source, destination_dir], verbose=verbose) def install(swift_exec: str, args: argparse.Namespace) -> None: swiftpm_args = get_swiftpm_options(swift_exec, args) additional_env = get_swiftpm_environment_variables(swift_exec, args) bin_path = swiftpm_bin_path(swift_exec, swiftpm_args=swiftpm_args, additional_env=additional_env) build_single_product('sourcekit-lsp', swift_exec, args) build_single_product('SwiftSourceKitPlugin', swift_exec, args) build_single_product('SwiftSourceKitClientPlugin', swift_exec, args) if platform.system() == 'Darwin': dynamic_library_extension = "dylib" else: dynamic_library_extension = "so" for prefix in args.install_prefixes: copy_file(os.path.join(bin_path, 'sourcekit-lsp'), os.path.join(prefix, 'bin'), verbose=args.verbose) copy_file(os.path.join(bin_path, f'libSwiftSourceKitPlugin.{dynamic_library_extension}'), os.path.join(prefix, 'lib'), verbose=args.verbose) copy_file(os.path.join(bin_path, f'libSwiftSourceKitClientPlugin.{dynamic_library_extension}'), os.path.join(prefix, 'lib'), verbose=args.verbose) copy_file(os.path.join(args.package_path, 'config.schema.json'), os.path.join(prefix, 'share', 'sourcekit-lsp'), verbose=args.verbose) def handle_invocation(swift_exec: str, args: argparse.Namespace) -> None: """ Depending on the action in 'args', build the package, installs the package or run tests. """ if args.clean: print('Cleaning ' + args.build_path) shutil.rmtree(args.build_path, ignore_errors=True) if args.action == 'build': build_single_product("sourcekit-lsp", swift_exec, args) build_single_product('SwiftSourceKitPlugin', swift_exec, args) build_single_product('SwiftSourceKitClientPlugin', swift_exec, args) elif args.action == 'test': run_tests(swift_exec, args) elif args.action == 'install': install(swift_exec, args) else: fatal_error(f"unknown action '{args.action}'") # ----------------------------------------------------------------------------- # Argument parsing def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description='Build along with the Swift build-script.') def add_common_args(parser: argparse.ArgumentParser) -> None: parser.add_argument('--package-path', metavar='PATH', help='directory of the package to build', default='.') parser.add_argument('--toolchain', required=True, metavar='PATH', help='build using the toolchain at PATH') parser.add_argument('--ninja-bin', metavar='PATH', help='ninja binary to use for testing') parser.add_argument('--build-path', metavar='PATH', default='.build', help='build in the given path') parser.add_argument('--configuration', '-c', default='debug', help='build using configuration (release|debug)') parser.add_argument('--no-local-deps', action='store_true', help='use normal remote dependencies when building') parser.add_argument('--sanitize', action='append', help='build using the given sanitizer(s) (address|thread|undefined)') parser.add_argument('--sanitize-all', action='store_true', help='build using every available sanitizer in sub-directories of build path') parser.add_argument('--clean', action='store_true', help='Clean the build directory prior to performing the action') parser.add_argument('--verbose', '-v', action='store_true', help='enable verbose output') parser.add_argument('--cross-compile-host', help='cross-compile for another host instead') parser.add_argument('--cross-compile-config', help='an SPM JSON destination file containing Swift cross-compilation flags') parser.add_argument('--multiroot-data-file', help='path to an Xcode workspace to create a unified build of all of Swift\'s SwiftPM projects') if sys.version_info >= (3, 7, 0): subparsers = parser.add_subparsers(title='subcommands', dest='action', required=True, metavar='action') else: subparsers = parser.add_subparsers(title='subcommands', dest='action', metavar='action') build_parser = subparsers.add_parser('build', help='build the package') add_common_args(build_parser) test_parser = subparsers.add_parser('test', help='test the package') add_common_args(test_parser) test_parser.add_argument('--skip-long-tests', action='store_true', help='skip run long-running tests') install_parser = subparsers.add_parser('install', help='build the package') add_common_args(install_parser) install_parser.add_argument('--prefix', dest='install_prefixes', nargs='*', metavar='PATHS', help="paths to install sourcekit-lsp, default: 'toolchain/bin'") args = parser.parse_args(sys.argv[1:]) if args.sanitize and args.sanitize_all: fatal_error('cannot combine --sanitize with --sanitize-all') # Canonicalize paths args.package_path = os.path.abspath(args.package_path) args.build_path = os.path.abspath(args.build_path) args.toolchain = os.path.abspath(args.toolchain) if args.action == 'install': if not args.install_prefixes: args.install_prefixes = [args.toolchain] return args def main() -> None: args = parse_args() if args.toolchain: swift_exec = os.path.join(args.toolchain, 'bin', 'swift') else: swift_exec = 'swift' handle_invocation(swift_exec, args) if args.sanitize_all: base = args.build_path print('=== %s sourcekit-lsp with asan ===' % args.action) args.sanitize = ['address'] args.build_path = os.path.join(base, 'test-asan') handle_invocation(swift_exec, args) print('=== %s sourcekit-lsp with tsan ===' % args.action) args.sanitize = ['thread'] args.build_path = os.path.join(base, 'test-tsan') handle_invocation(swift_exec, args) # Linux ubsan disabled: https://bugs.swift.org/browse/SR-12550 if platform.system() != 'Linux': print('=== %s sourcekit-lsp with ubsan ===' % args.action) args.sanitize = ['undefined'] args.build_path = os.path.join(base, 'test-ubsan') handle_invocation(swift_exec, args) if __name__ == '__main__': main()