#!/usr/bin/python
# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.

# amalgamate.py creates an amalgamation from a unity build.
# It can be run with either Python 2 or 3.
# An amalgamation consists of a header that includes the contents of all public
# headers and a source file that includes the contents of all source files and
# private headers.
#
# This script works by starting with the unity build file and recursively expanding
# #include directives. If the #include is found in a public include directory,
# that header is expanded into the amalgamation header.
#
# A particular header is only expanded once, so this script will
# break if there are multiple inclusions of the same header that are expected to
# expand differently. Similarly, this type of code causes issues:
#
# #ifdef FOO
#   #include "bar.h"
#   // code here
# #else
#   #include "bar.h"            // oops, doesn't get expanded
#   // different code here
# #endif
#
# The solution is to move the include out of the #ifdef.


import argparse
import re
import sys
from os import path

include_re = re.compile('^[ \t]*#include[ \t]+"(.*)"[ \t]*$')
included = set()
excluded = set()


def find_header(name, abs_path, include_paths):
    samedir = path.join(path.dirname(abs_path), name)
    if path.exists(samedir):
        return samedir
    for include_path in include_paths:
        include_path = path.join(include_path, name)
        if path.exists(include_path):
            return include_path
    return None


def expand_include(
    include_path,
    f,
    abs_path,
    source_out,
    header_out,
    include_paths,
    public_include_paths,
):
    if include_path in included:
        return False

    included.add(include_path)
    with open(include_path) as f:
        print(f'#line 1 "{include_path}"', file=source_out)
        process_file(
            f, include_path, source_out, header_out, include_paths, public_include_paths
        )
    return True


def process_file(
    f, abs_path, source_out, header_out, include_paths, public_include_paths
):
    for (line, text) in enumerate(f):
        m = include_re.match(text)
        if m:
            filename = m.groups()[0]
            # first check private headers
            include_path = find_header(filename, abs_path, include_paths)
            if include_path:
                if include_path in excluded:
                    source_out.write(text)
                    expanded = False
                else:
                    expanded = expand_include(
                        include_path,
                        f,
                        abs_path,
                        source_out,
                        header_out,
                        include_paths,
                        public_include_paths,
                    )
            else:
                # now try public headers
                include_path = find_header(filename, abs_path, public_include_paths)
                if include_path:
                    # found public header
                    expanded = False
                    if include_path in excluded:
                        source_out.write(text)
                    else:
                        expand_include(
                            include_path,
                            f,
                            abs_path,
                            header_out,
                            None,
                            public_include_paths,
                            [],
                        )
                else:
                    sys.exit(
                        "unable to find {}, included in {} on line {}".format(
                            filename, abs_path, line
                        )
                    )

            if expanded:
                print(f'#line {line + 1} "{abs_path}"', file=source_out)
        elif text != "#pragma once\n":
            source_out.write(text)


def main():
    parser = argparse.ArgumentParser(
        description="Transform a unity build into an amalgamation"
    )
    parser.add_argument("source", help="source file")
    parser.add_argument(
        "-I",
        action="append",
        dest="include_paths",
        help="include paths for private headers",
    )
    parser.add_argument(
        "-i",
        action="append",
        dest="public_include_paths",
        help="include paths for public headers",
    )
    parser.add_argument(
        "-x", action="append", dest="excluded", help="excluded header files"
    )
    parser.add_argument("-o", dest="source_out", help="output C++ file", required=True)
    parser.add_argument(
        "-H", dest="header_out", help="output C++ header file", required=True
    )
    args = parser.parse_args()

    include_paths = list(map(path.abspath, args.include_paths or []))
    public_include_paths = list(map(path.abspath, args.public_include_paths or []))
    excluded.update(map(path.abspath, args.excluded or []))
    filename = args.source
    abs_path = path.abspath(filename)
    with open(filename) as f, open(args.source_out, "w") as source_out, open(
        args.header_out, "w"
    ) as header_out:
        print(f'#line 1 "{filename}"', file=source_out)
        print(f'#include "{header_out.name}"', file=source_out)
        process_file(
            f, abs_path, source_out, header_out, include_paths, public_include_paths
        )


if __name__ == "__main__":
    main()
