#!/usr/bin/env python3

# Copyright (c) 2015-2021 Nicholas Fraser and the MPack authors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of
# this software and associated documentation files (the "Software"), to deal in
# the Software without restriction, including without limitation the rights to
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
# the Software, and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

# This is the buildsystem configuration tool for the MPack unit test suite. It
# tests the compiler for support for various flags and features and generates a
# ninja build file to build the unit test suite in a variety of configurations.
#
# It can be run with a GCC-style compiler or with MSVC. To run it with MSVC,
# you must first have the Visual Studio build tools on your path. This means
# you need to either open a Visual Studio Build Tools command prompt, or source
# vsvarsall.bat for some version of the Visual Studio Build Tools.

import shutil, os, sys, subprocess
from os import path

globalbuild = path.join(".build", "unit")
os.makedirs(globalbuild, exist_ok=True)



###################################################
# Determine Compiler
###################################################

cc = None
compiler = "unknown"

if os.getenv("CC"):
    cc = os.getenv("CC")
elif shutil.which("cl.exe"):
    cc = "cl"
else:
    cc = "cc"

if not shutil.which(cc):
    raise Exception("Compiler cannot be found!")

if cc.lower() == "cl" or cc.lower() == "cl.exe":
    compiler = "MSVC"
elif cc.endswith("cproc"):
    compiler = "cproc"
elif cc.endswith("chibicc"):
    compiler = "chibicc"
elif cc.endswith("8cc"):
    compiler = "8cc"
else:
    # try --version
    ret = subprocess.run([cc, "--version"], universal_newlines=True,
            stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    if ret.returncode == 0:
        if ret.stdout.startswith("cparser "):
            compiler = "cparser"
        elif "clang" in ret.stdout:
            compiler = "Clang"

    if compiler == "unknown":
        # try -v
        ret = subprocess.run([cc, "-v"], universal_newlines=True,
                stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        if ret.returncode == 0:
            for line in (ret.stdout + "\n" + ret.stderr).splitlines():
                if line.startswith("tcc "):
                    compiler = "TinyCC"
                    break
                elif line.startswith("gcc "):
                    compiler = "GCC"
                    break

print("Using " + compiler + " compiler with executable: " + cc)

if compiler == "MSVC":
    obj_extension = ".obj"
    exe_extension = ".exe"
else:
    obj_extension = ".o"
    exe_extension = ""

msvc = (compiler == "MSVC")



###################################################
# Compiler Probing
###################################################

config = {
    "flags": {},
}

flagtest_src = path.join(globalbuild, "flagtest.c")
flagtest_exe = path.join(globalbuild, "flagtest" + exe_extension)
with open(flagtest_src, "w") as out:
    out.write("""
int main(int argc, char** argv) {
    // array dereference to test for the existence of
    // sanitizer libs when using -fsanitize (libubsan)
    // compare it to another string in the array so that
    // -Wzero-as-null-pointer-constant works
    return argv[argc - 1] == argv[0];
}
""")

def checkFlags(flags):
    if isinstance(flags, str):
        flags = [flags,]

    configArg = "|".join(flags)
    if configArg in config["flags"]:
        return config["flags"][configArg]
    print("Testing flag(s): " + " ".join(flags) + " ... ", end="")
    sys.stdout.flush()

    if msvc:
        cmd = [cc, "/WX"] + flags + [flagtest_src, "/Fe" + flagtest_exe]
    else:
        cmd = [cc, "-Werror"] + flags + [flagtest_src, "-o", flagtest_exe]
    ret = subprocess.run(cmd, universal_newlines=True,
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT)

    if ret.returncode == 0:
        print("Supported.")
        supported = True
    else:
        print("Not supported.")
        supported = False
    config["flags"][configArg] = supported
    return supported

def flagsIfSupported(flags):
    if checkFlags(flags):
        if isinstance(flags, str):
            return [flags]
        return flags
    return []

# We use -Og for all debug builds if we have it, but ONLY under GCC. It can
# sometimes improve warnings, and things run a lot faster especially under
# Valgrind, but Clang stupidly maps it to -O1 which has some optimizations
# that break debugging!
hasOg = False
print("Testing flag(s): -Og ... ", end="")
sys.stdout.flush()
if msvc:
    print("Not supported.")
else:
    ret = subprocess.run([cc, "-v"], universal_newlines=True,
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    sys.stdout.flush()
    if ret.returncode != 0:
        print("Not supported.")
    else:
        for line in ret.stdout.splitlines():
            if line.startswith("gcc version"):
                hasOg = True
                break
        if hasOg:
            print("Supported.")
        else:
            print("May be supported, but we won't use it.")



###################################################
# Common Flags
###################################################

global_cppflags = []

if msvc:
    global_cppflags += [
        "/W4", "/WX",
        # debug to PDB with synchronous writes since we're doing parallel builds
        # (we specify a per-build PDB path during build generation below)
        "/Zi", "/FS"
    ]
else:
    global_cppflags += [
        "-Wall", "-Wextra", "-Werror",
        "-Wconversion", "-Wundef",
        "-Wshadow", "-Wcast-qual",
        "-g",
    ]

global_cppflags += [
    "-Isrc", "-Itest/unit/src",
    "-DMPACK_VARIANT_BUILDS=1",
    "-DMPACK_HAS_CONFIG=1",
]

defaultfeatures = [
    "-DMPACK_READER=1",
    "-DMPACK_WRITER=1",
    "-DMPACK_EXPECT=1",
    "-DMPACK_NODE=1",
]

allfeatures = defaultfeatures + [
    "-DMPACK_COMPATIBILITY=1",
    "-DMPACK_EXTENSIONS=1",
]

noioconfigs = [
    "-DMPACK_STDLIB=1",
    "-DMPACK_MALLOC=test_malloc",
    "-DMPACK_FREE=test_free",
]

allconfigs = noioconfigs + [
    "-DMPACK_STDIO=1",
]

# optimization
if msvc:
    debugflags = ["/Od", "/MDd"]
    releaseflags = ["/O2", "/MD"]
else:
    debugflags = [hasOg and "-Og" or "-O0"]
    releaseflags = ["-O2"]
debugflags.append("-DDEBUG")
releaseflags.append("-DNDEBUG")

# flags for specifying source language
cxxlinkflags = []
if msvc:
    cflags = ["/TC"]
    cxxflags = ["/TP", "/EHsc"]
else:
    cflags = [
        checkFlags("-std=c11") and "-std=c11" or "-std=c99",
        "-Wc++-compat"
    ]
    cxxflags = [
        "-x", "c++",
        "-Wmissing-declarations",
    ]
    # When building as C++ on macOS, clang will emit calls to std::terminate
    # even if no C++ features are used so libc++ is required. We link with cc,
    # not c++ so we need to link it manually, and there is apparently no way to
    # link it statically either. (This overrides the use of libstdc++ if libc++
    # is installed so it's not ideal. We can worry about this later.)
    if checkFlags(cxxflags + ["-lc++"]):
        cxxlinkflags.append("-lc++")



###################################################
# Variable Flags
###################################################

if not os.getenv("CI") and not msvc:
    # we have to force color diagnostics to get color output from ninja
    # (ninja will strip the colors if it's being piped)
    if checkFlags("-fdiagnostics-color=always"):
        global_cppflags.append("-fdiagnostics-color=always")
    elif checkFlags("-fcolor-diagnostics=always"):
        global_cppflags.append("-color-diagnostics=always")

if checkFlags("-Wstrict-aliasing=3"):
    global_cppflags.append("-Wstrict-aliasing=3")
elif checkFlags("-Wstrict-aliasing=2"):
    global_cppflags.append("-Wstrict-aliasing=2")
elif checkFlags("-Wstrict-aliasing"):
    global_cppflags.append("-Wstrict-aliasing")

extra_warnings_to_test = [
    "-Wpedantic",
    "-Wmissing-variable-declarations",
    "-Wfloat-conversion",
]
if not msvc:
    extra_warnings_to_test += ["-fstrict-aliasing"]

for flag in extra_warnings_to_test:
    global_cppflags += flagsIfSupported(flag)

cflags += flagsIfSupported("-Wmissing-prototypes")
cflags += flagsIfSupported("-Wstrict-prototypes")

#TODO
ltoflags = []



###################################################
# Build configuration
###################################################

builds = {}

class Build:
    def __init__(self, name, cppflags, ldflags):
        self.name = name
        self.cppflags = cppflags
        self.ldflags = ldflags
        self.run_wrapper = None
        self.exclude = False

def addBuild(name, cppflags, ldflags=[]):
    builds[name] = Build(name, cppflags[:], ldflags[:])

def addDebugReleaseBuilds(name, cppflags, ldflags = []):
    addBuild(name + "-debug", cppflags + debugflags, ldflags)
    addBuild(name + "-release", cppflags + releaseflags, ldflags)

addDebugReleaseBuilds('default', defaultfeatures + allconfigs + cflags)
addDebugReleaseBuilds('everything', allfeatures + allconfigs + cflags)
addDebugReleaseBuilds('empty', allconfigs + cflags)
addDebugReleaseBuilds('reader', ["-DMPACK_READER=1"] + allconfigs + cflags)
addDebugReleaseBuilds('expect', ["-DMPACK_READER=1", "-DMPACK_EXPECT=1"] + allconfigs + cflags)
addDebugReleaseBuilds('node', ["-DMPACK_NODE=1"] + allconfigs + cflags)
addDebugReleaseBuilds('compatibility', defaultfeatures + ["-DMPACK_COMPATIBILITY=1"] + allconfigs + cflags)
addDebugReleaseBuilds('extensions', defaultfeatures + ["-DMPACK_EXTENSIONS=1"] + allconfigs + cflags)
addDebugReleaseBuilds('no-float', allfeatures + allconfigs + cflags + ["-DMPACK_FLOAT=0"])
addDebugReleaseBuilds('no-double', allfeatures + allconfigs + cflags + ["-DMPACK_DOUBLE=0"])

# writer builds
addDebugReleaseBuilds('writer-only',
        ["-DMPACK_WRITER=1", "-DMPACK_BUILDER=0"]
        + allconfigs + cflags)
addDebugReleaseBuilds('builder-internal',
        ["-DMPACK_WRITER=1", "-DMPACK_BUILDER=1", "-DMPACK_BUILDER_INTERNAL_STORAGE=1"]
        + allconfigs + cflags)
addDebugReleaseBuilds('builder-nointernal',
        ["-DMPACK_WRITER=1", "-DMPACK_BUILDER=1", "-DMPACK_BUILDER_INTERNAL_STORAGE=0"]
        + allconfigs + cflags)

# no i/o
addDebugReleaseBuilds('noio', allfeatures + noioconfigs + cflags)
addDebugReleaseBuilds('noio-writer', ["-DMPACK_WRITER=1"] + noioconfigs + cflags)
addDebugReleaseBuilds('noio-reader', ["-DMPACK_READER=1"] + noioconfigs + cflags)
addDebugReleaseBuilds('noio-expect', ["-DMPACK_READER=1", "-DMPACK_EXPECT=1"] + noioconfigs + cflags)
addDebugReleaseBuilds('noio-node', ["-DMPACK_NODE=1"] + noioconfigs + cflags)

# embedded builds without libc (using builtins)
addDebugReleaseBuilds('embed', allfeatures + cflags)
addDebugReleaseBuilds('embed-writer', ["-DMPACK_WRITER=1"] + cflags)
addDebugReleaseBuilds('embed-reader', ["-DMPACK_READER=1"] + cflags)
addDebugReleaseBuilds('embed-expect', ["-DMPACK_READER=1", "-DMPACK_EXPECT=1"] + cflags)
addDebugReleaseBuilds('embed-node', ["-DMPACK_NODE=1"] + cflags)
addDebugReleaseBuilds('embed-nobuiltins', ["-DMPACK_NO_BUILTINS=1"] + allfeatures + cflags)

haveValgrind = shutil.which("valgrind")

if haveValgrind:
    # use valgrind for everything build if available
    builds["everything-debug"].run_wrapper = "valgrind"
    builds["everything-release"].run_wrapper = "valgrind"

# language versions
if msvc:
    addDebugReleaseBuilds('c++', allfeatures + allconfigs + cxxflags)
elif compiler != "TinyCC":

    # MPack is really C11 code with C++ support. We need lots of compiler
    # extensions to build as ANSI C. We technically only support gnu89 so we
    # need to disable pedantic C89 warnings. We add
    # -Wdeclaration-after-statement even though MPack mixes declarations and
    # code just to make sure MPack disables the warning properly.
    gnu89flags = ["-std=gnu89", "-Wno-pedantic", "-Wdeclaration-after-statement"]
    if checkFlags(gnu89flags):
        addDebugReleaseBuilds('gnu89', allfeatures + allconfigs + gnu89flags)

    if checkFlags("-std=c11"):
        # if we're using c11 for everything else, we still need to test c99
        addDebugReleaseBuilds('c99', allfeatures + allconfigs + ["-std=c99"])

    for version in ["c++11", "gnu++11", "c++14", "c++17"]:
        flags = cxxflags + ["-std=" + version]
        if checkFlags(flags):
            addDebugReleaseBuilds(version, allfeatures + allconfigs + flags, cxxlinkflags)

    # Make sure C++11 compiles with disabled features (see #66)
    cxx11flags = cxxflags + ["-std=c++11"]
    if checkFlags(cxx11flags):
        addDebugReleaseBuilds('c++11-empty', allconfigs + cxx11flags, cxxlinkflags)

    # We disable pedantic in C++98 due to our use of variadic macros, trailing
    # commas, ll format specifiers, and probably more. We technically only support
    # C++98 with those extensions.
    cxx98flags = cxxflags + ["-std=c++98"]
    if checkFlags("-Wno-pedantic"):
        cxx98flags += ["-Wno-pedantic"]
    addDebugReleaseBuilds('c++98', allfeatures + allconfigs + cxx98flags, cxxlinkflags)

# 32-bit builds
if not msvc and checkFlags("-m32"):
    addDebugReleaseBuilds('m32', allfeatures + allconfigs + cflags + ["-m32"], ["-m32"])
    addDebugReleaseBuilds('cxx98-m32', allfeatures + allconfigs + cxx98flags + ["-m32"], ["-m32"])
    if checkFlags(cxx11flags):
        addDebugReleaseBuilds('c++11-m32', allfeatures + allconfigs + cxx11flags + ["-m32"], ["-m32"])

# lto build
if msvc:
    addBuild('lto', allfeatures + allconfigs + cflags + releaseflags + ["/GL"], ["/LTCG"])
elif compiler != "TinyCC":
    ltoflags = ["-O3", "-flto", "-fuse-linker-plugin", "-fno-fat-lto-objects"]
    if checkFlags(ltoflags):
        ltoflags = allfeatures + allconfigs + cflags + ltoflags
    else:
        ltoflags = ["-O3", "-flto"]
        if checkFlags(ltoflags):
            ltoflags = allfeatures + allconfigs + cflags + ltoflags
        else:
            ltoflags = None
    if ltoflags:
        addBuild('lto', ltoflags, ltoflags)
        if haveValgrind:
            builds["lto"].run_wrapper = "valgrind"

# size-optimized build (both debug and release)
if msvc:
    sizeOptimize = ["/O1", "/MD"]
else:
    sizeOptimize = ["-Os"]
addBuild('optimize-size-debug', allfeatures + allconfigs + cflags + sizeOptimize +
        ["-DMPACK_OPTIMIZE_FOR_SIZE=1", "-DMPACK_STRINGS=0", "-DDEBUG"])
addBuild('optimize-size-release', allfeatures + allconfigs + cflags + sizeOptimize +
        ["-DMPACK_OPTIMIZE_FOR_SIZE=1", "-DMPACK_STRINGS=0", "-DNDEBUG"])

# miscellaneous special builds
addBuild('notrack', allfeatures + allconfigs + cflags + debugflags + ["-DMPACK_NO_TRACKING=1"])
addDebugReleaseBuilds('realloc', allfeatures + allconfigs + cflags + ["-DMPACK_REALLOC=test_realloc"])
if not msvc and compiler != "TinyCC":
    addBuild('O3', allfeatures + allconfigs + cflags + ["-O3"])
    if haveValgrind:
        builds["O3"].run_wrapper = "valgrind"
    addBuild('fastmath', allfeatures + allconfigs + cflags + ["-ffast-math"])
    if haveValgrind:
        builds["fastmath"].run_wrapper = "valgrind"
    addBuild('coverage', allfeatures + allconfigs + cflags +
            ["-DMPACK_GCOV=1", "--coverage", "-fno-inline", "-fno-inline-small-functions", "-fno-default-inline"],
            ["--coverage"])
    builds["coverage"].exclude = True # don't run coverage during "all". run separately by CI.
    if hasOg:
        addBuild('O0', allfeatures + allconfigs + cflags + ["-DDEBUG", "-O0"])

# sanitizers
if msvc:
    # https://devblogs.microsoft.com/cppblog/asan-for-windows-x64-and-debug-build-support/
    # /INFERASANLIBS is enabled by default, no need to specify them anymore
    addDebugReleaseBuilds('sanitize-address', allfeatures + allconfigs + cflags + ["/fsanitize=address"])
elif compiler != "TinyCC":
    def addSanitizerBuilds(name, cppflags, ldflags=[]):
        if checkFlags(cppflags):
            addDebugReleaseBuilds(name, allfeatures + allconfigs + cflags + cppflags, ldflags)
    addSanitizerBuilds('sanitize-undefined', ["-fsanitize=undefined"], ["-fsanitize=undefined"])
    addSanitizerBuilds('sanitize-safe-stack', ["-fsanitize=safe-stack"], ["-fsanitize=safe-stack"])
    addSanitizerBuilds('sanitize-address', ["-fsanitize=address"], ["-fsanitize=address"])
    addSanitizerBuilds('sanitize-memory', ["-fsanitize=memory"], ["-fsanitize=memory"])
    # not technically a sanitizer, but close enough:
    addSanitizerBuilds('sanitize-stack-protector', ["-Wstack-protector", "-fstack-protector-all"])



###################################################
# Ninja generation
###################################################

srcs = []

for paths in [path.join("src", "mpack"), path.join("test", "unit", "src")]:
    for root, dirs, files in os.walk(paths):
        for name in files:
            if name.endswith(".c"):
                srcs.append(os.path.join(root, name))

ninja = path.join(globalbuild, "build.ninja")
with open(ninja, "w") as out:
    out.write("# This file is auto-generated.\n")
    out.write("# Do not edit it; your changes will be erased.\n")
    out.write("\n")

    # 1.3 for gcc deps, 1.1 for pool
    out.write("ninja_required_version = 1.3\n")
    out.write("\n")

    out.write("rule compile\n")
    if msvc:
        out.write(" command = " + cc + " /showIncludes $flags /c $in /Fo$out\n")
        out.write(" deps = msvc\n")
    else:
        out.write(" command = " + cc + " " + "-MD -MF $out.d $flags -c $in -o $out\n")
        out.write(" deps = gcc\n")
        out.write(" depfile = $out.d\n")
    out.write("\n")

    out.write("rule link\n")
    if msvc:
        out.write(" command = link $flags $in /OUT:$out\n")
    else:
        out.write(" command = " + cc + " $flags $in -o $out\n")
    out.write("\n")

    # unfortunately right now the unit tests all try to write to the same files,
    # so they break when run concurrently. we need to make it write to files under
    # that config's build/ folder; for now we just run them sequentially.
    out.write("pool run_pool\n")
    out.write(" depth = 1\n")
    out.write("run_wrapper =\n")
    out.write("rule run\n")
    out.write(" command = $run_wrapper$in\n")
    out.write(" pool = run_pool\n")
    out.write("\n")

    out.write("rule help\n")
    out.write(" command = cat .build/help\n")
    out.write("build help: help\n")
    out.write("\n")

    for buildname in sorted(builds.keys()):
        build = builds[buildname]
        buildfolder = path.join(globalbuild, buildname)
        cppflags = global_cppflags + build.cppflags
        ldflags = build.ldflags
        objs = []

        if msvc:
            # Specify a per-build PDB path so that we don't try to link at the
            # same time a PDB file is being written
            cppflags.append("/Fd" + buildfolder)
            ldflags.append("/DEBUG")

        for src in srcs:
            obj = path.join(buildfolder, "objs", src[:-2] + obj_extension)
            objs.append(obj)
            out.write("build " + obj + ": compile " + src + "\n")
            out.write(" flags = " + " ".join(cppflags) + "\n")

        runner = path.join(buildfolder, "runner") + exe_extension

        out.write("build " + runner + ": link " + " ".join(objs) + "\n")
        out.write(" flags = " + " ".join(ldflags) + "\n")

        # You can omit "run-" in front of any build to just build it without
        # running it. This lets you run it some other way (e.g. under gdb,
        # with/without Valgrind, etc.)
        out.write("build " + buildname + ": phony " + runner + "\n\n")

        out.write("build run-" + buildname + ": run " + runner + "\n")
        if build.run_wrapper:
            run_wrapper = build.run_wrapper
            out.write(" run_wrapper = " + run_wrapper + " ")
            if run_wrapper == "valgrind":
                out.write("--leak-check=full --error-exitcode=1 ")
                out.write("--suppressions=tools/valgrind-suppressions ")
                out.write("--show-leak-kinds=all --errors-for-leak-kinds=all ")
        out.write("\n")

    out.write("default run-everything-debug\n")
    out.write("build default: phony run-everything-debug\n")
    out.write("\n")

    # Builds included under the "more" target
    more = [
        "run-default-debug",
        "run-everything-debug",
        "run-everything-release",
        "run-embed-debug",
        "run-embed-release",
        "run-no-float-release",
    ]
    if "gnu89-debug" in builds:
        more += [
            "run-gnu89-debug",
            "run-gnu89-release",
        ]
    if "c++11-debug" in builds:
        more += [
            "run-c++11-debug",
        ]

    out.write("build more: phony " + " ".join(more))
    if ltoflags:
        out.write(" run-lto")
    out.write("\n\n")

    out.write("build all: phony")
    for build in sorted(builds.keys()):
        if not builds[build].exclude:
            out.write(" run-")
            out.write(build)
    out.write("\n")

print("Generated " + ninja)

with open(path.join(globalbuild, "help"), "w") as out:
    out.write("\n")
    out.write("Available targets:\n")
    out.write("\n")
    out.write("    (default)\n")
    out.write("    more\n")
    out.write("    all\n")
    out.write("    clean\n")
    out.write("    help\n")
    out.write("\n")
    for build in sorted(builds.keys()):
        out.write("    run-" + build + "\n")
    out.close()
