#!/usr/bin/env python
import argparse
import copy
import glob
import json
import multiprocessing
import os
import re
import shutil
import yaml
import platform

from colorama import Fore, Style, init

# Initialize colorama
init()

# Colors
class colors:
    if platform.system() == 'Windows':
        OKBLUE = Fore.BLUE
        OKCYAN = Fore.CYAN
        OKGREEN = Fore.GREEN
        WARNING = Fore.YELLOW
        FAIL = Fore.RED
        RESET = Style.RESET_ALL
        BOLD = Style.BRIGHT
        UNDERLINE = ''
        PURPLE = Fore.MAGENTA
    else:
        OKBLUE = '\033[94m'
        OKCYAN = '\033[96m'
        OKGREEN = '\033[92m'
        WARNING = '\033[93m'
        FAIL = '\033[91m'
        RESET = '\033[0m'
        BOLD = '\033[1m'
        UNDERLINE = '\033[4m'
        PURPLE = '\033[95m'

# Generate error message
def error(type, color, message):
    return colors.BOLD + 'smake: ' + color + type + ': ' + colors.RESET + message

# Assert that a file exists
def assert_file(file_path):
    if not os.path.exists(file_path):
        msg = 'file ' + file_path + ' does not exist'
        print(error('fatal error', colors.FAIL, msg))
        exit(-1)

# Script class
class Script:
    def __init__(self, *kargs):
        self.scripts = kargs

    def run(self, *args):
        str_args = ' '.join(args)
        for script in self.scripts:
            cmd = script.replace('{}', str_args)
            ret = os.system(cmd)

            # TODO: print error
            if (ret != 0):
                exit(-1)

# Make a directory
def mkdir(dir):
    # Need to incrementally check if each subdir exists
    subdirs = dir.split('/')

    dir = ''
    while True:
        dir += subdirs.pop(0) + '/'
        if not os.path.exists(dir):
            os.mkdir(dir)
        if len(subdirs) == 0:
            break

def make_ofile(file, ofmt):
    # Get extension index
    ext_idx = file.rfind('.')
    if ext_idx == -1:
        ext_idx = len(file)

    if ofmt == None:
        # Get file name without directory
        return file[:ext_idx].replace('/', '_') + '.o'

    # Get plain filename
    dir_idx = file.rfind('/')
    filename = file[dir_idx + 1:ext_idx]

    # Replace fixed groups
    s = ofmt.replace('{}', file.replace('/', '_')) \
        .replace('{filename}', filename) \
        .replace('{dirname}', file[:dir_idx])

    return s

# Build class
class Build:
    cache = '.smake'
    bdir = '.smake/builds'
    tdir = '.smake/targets'

    # Default compiler and standard
    default_compiler = 'g++'
    default_standard = 'c++11'

    # Find header from include and source directories
    # TODO: needs optimization
    def get_header(self, source, header):
        # First check the header from source path
        sdir = os.path.dirname(source)
        hpath = sdir + '/' + header

        if os.path.exists(hpath):
            return os.path.normpath(hpath)

        # Check directories of the source first
        for sdir in self.sdirs:
            path = os.path.join(sdir, header)
            if os.path.exists(path):
                return path

        # Check include directories
        for idir in self.idirs:
            path = os.path.join(idir, header)
            if os.path.exists(path):
                return path

        # Assume its standard
        return None

    # Get header dependencies for a source
    #   which are not the standard library
    def get_deps(self, source):
        assert_file(source)
        with open(source, 'rb') as file:
            content = file.read().decode('iso-8859-1')
            lines = content.split('\n')

        for line in lines:
            if line.startswith('#include'):
                # TODO: join regexes?
                ht1 = re.search('#include <(.+)>', line)
                ht2 = re.search('#include \"(.+)\"', line)

                header = None
                if ht1:
                    header = self.get_header(source, ht1.group(1))
                elif ht2:
                    header = self.get_header(source, ht2.group(1))

                # TODO: improve dependency checking
                if header and header not in self.all_deps:
                    self.all_deps.add(header)
                    self.get_deps(header)

        # Deep copy so that the dictionary does
        # not get modified from future dependency searches
        return copy.deepcopy(self.all_deps)

    # Rebuild dependencies
    def rebuild_deps(self, dfile, sources):
        # Create dependency map for each source
        self.deps = dict()
        for source in sources:
            self.all_deps = set()   # Temporary variable for recursion

            # Get the dependencies and convert to a list
            sdeps = self.get_deps(source)
            sdeps = list(sdeps)

            self.deps.update({source: sdeps})

        # Store the dependencies to a file
        with open(dfile, 'w', encoding='utf-8') as file:
            json.dump(self.deps, file, ensure_ascii=False, indent=4)

    # Build dependencies
    def build_deps(self, sources):
        # Dependency path
        dfile = self.bdir + '/' + self.build + '_dependencies.json'

        # Rebuild immediately if smake.yaml is more recent
        #   than the dependency file
        if os.path.exists(dfile) and (os.path.getmtime(dfile) < os.path.getmtime('smake.yaml')):
            msg = 'rebuilding dependencies for ' + self.build + '...'
            print(error('info', colors.OKCYAN, msg))

            # Clear the directory of objects
            assert(self.odir != '.')
            for file in glob.glob(self.odir + '/*'):
                os.remove(file)


            # Rebuild dependencies
            return self.rebuild_deps(dfile, sources)

        # Check if the file exists
        if os.path.exists(dfile):
            # Get file time
            dtime = os.path.getmtime(dfile)

            # Read the JSON
            with open(dfile) as file:
                deps = json.load(file)

            # Convert to set
            l = []
            for source in deps:
                l.append(source)

                # TODO: helper function
                for dep in deps[source]:
                    if not os.path.exists(dep):
                        # Check time source was last modified
                        mt = os.path.getmtime(source)
                        if mt > dtime:
                            # Rebuild dependencies
                            self.rebuild_deps(dfile, sources)
                            return

                        # TODO: later, report the line in which the header is included
                        msg = 'source file ' + source + ' includes ' + \
                            dep + ' which does not exist'
                        print(error('fatal error', colors.FAIL, msg))
                        exit(-1)

                    l.append(dep)

            # Check if the dependencies file needs to be updated
            mtime = os.path.getmtime(l[0])

            for source in l:
                assert_file(source)
                mt = os.path.getmtime(source)
                if mt > mtime:
                    mtime = mt

            # If the dependencies file is up to date
            # (with dep file AND smake file), return
            if (mtime <= dtime) and (dtime >= os.path.getmtime('smake.yaml')):
                self.deps = deps
                return
            elif (dtime < os.path.getmtime('smake.yaml')):
                # If dependencies are simply not up to date
                #   then need to delete old cache directory (self.odir)
                if os.path.exists(self.odir):
                    shutil.rmtree(self.odir)

                os.mkdir(self.odir)

        # Rebuild dependencies
        self.rebuild_deps(dfile, sources)

    # Build is build name
    def __init__(self, config, target, build,
            compiler, sources, dependencies,
            idirs = [], libs = [], ldirs = [],
            flags = [], linkage_flags = [], options = {}):

        # Set immediate properties
        self.config = config
        self.target = target
        self.build = build
        self.sources = sources
        self.dependencies = dependencies
        self.compiler = compiler

        # TODO: make function for directory creation
        # Create the cache directory
        if not os.path.exists(self.cache):
            os.mkdir(self.cache)

        # Create build dir
        if not os.path.exists(self.bdir):
            os.mkdir(self.bdir)

        # Create the output directory
        self.odir = self.bdir + '/' + self.build
        if not os.path.exists(self.odir):
            os.mkdir(self.odir)

        if options['output_dir'] != None:
            mkdir(options['output_dir'])
            self.odir = options['output_dir']

        # Check if the target name should be overridden
        if options['target_name'] != None:
            self.target = options['target_name']

        # Output object format
        self.ofmt = None
        if options['output_format'] != None:
            self.ofmt = options['output_format']

        # Check if the output should be linked
        self.nolink = False
        if options['nolink'] != None:
            self.nolink = bool(options['nolink'])

        # Check if the output is a dynamic library
        self.dynamic = False
        if options['dll'] != None:
            self.dynamic = bool(options['dll'])

        # Create target build directory
        # TODO: allow separate build and output dir...
        if options['output_dir'] == None:
            self.tpath = self.tdir + '/' + self.target
            if not os.path.exists(self.tdir):
                os.mkdir(self.tdir)
        else:
            self.tpath = options['output_dir'] + '/' + self.target

        # Includes
        self.idirs = ''
        if len(idirs) > 0:
            self.idirs = ''.join([' -I ' + idir for idir in idirs])

        # Library dirs and rpath
        self.ldirs = ''
        if len(ldirs) > 0:
            self.ldirs = ''.join([' -L' + ldir for ldir in ldirs])

        # Runtime paths
        self.rpaths = ''
        if len(ldirs) > 0:
            if self.compiler == 'nvcc':
                self.rpaths += '-Xlinker -rpath -Xlinker ' + ':'.join(ldirs)
            else:
                self.rpaths += ' -Wl,'
                self.rpaths += ''.join(['-rpath ' + ldir for ldir in ldirs])

        # Libraries
        self.libs = ''
        if len(libs) > 0:
            self.libs = ''.join([lib if (lib[0] == '-') else (' -l' + lib) for lib in libs])

        # Flags
        self.flags = flags
        if len(flags) == 0:
            self.flags = {'default' : ['-std={}'.format(self.default_standard)]}

        # Linkage flags
        self.linkage_flags = ''
        if len(linkage_flags) > 0:
            self.linkage_flags = ' '.join(linkage_flags)

        # Populate the directory for each source
        self.sdirs = set()
        for source in sources:
            self.sdirs.add(os.path.dirname(source))

        # Build dependencies
        self.build_deps(sources)

    # Compile all the flags together
    def compile_flags(self, source):
        # Get flags
        flags = self.flags['default'] if 'default' in self.flags else ''
        if source in self.flags:
            flags = self.flags[source]

        flags = ' '.join(flags)

        return flags

    # Update target name
    def set_target(self, target):
        self.target = target
        self.tpath = self.tdir + '/' + self.target

    # Get most recent time from source and its dependencies
    def getmtime(self, file):
        assert_file(file)
        mtime = os.path.getmtime(file)
        for dep in self.deps[file]:
            mt = os.path.getmtime(dep)
            if mt > mtime:
                mtime = mt
        return mtime

    # Generate compile_commands.json
    def gen_ccmds(self):
        # Return string
        ret = ''

        # Iterate through all the sources
        for file in self.sources:
            ofile = self.odir + '/' + make_ofile(file, self.ofmt)

            # Generate command
            flags = self.compile_flags(file)

            cmd = '{} {} -c -o {} {} {}'.format(self.compiler, flags,
                    ofile, file, self.idirs)

            ret += '{{\n  "directory": "{}",\n  "command": "{}",\n  "file": "{}"\n}},\n'.format(
                    os.getcwd(), cmd, file)

        # Return the string
        return ret


    # Compile a single file
    def compile(self, lock_avaiable, file, msg, verbose = False):
        # Lock if available
        if lock_avaiable:
            lock.acquire()

        # Print the messahe
        print(msg, end='')

        # Check if file exists
        if not os.path.exists(file):
            print(colors.FAIL + f'\tFile {file} does not exist' + colors.RESET)
            return ''

        ofile = self.odir + '/' + make_ofile(file, self.ofmt)

        # Check if compilation is necessary
        file_t = self.getmtime(file)

        if os.path.exists(ofile):
            ofile_t = os.path.getmtime(ofile)
            if ofile_t > file_t:
                print(colors.OKCYAN + 'Source already compiled' + colors.RESET)

                # Unlock if available
                if lock_avaiable:
                    lock.release()

                return ofile

        # Compile the source
        flags = self.compile_flags(file)
        
        cmd = '{} {} -c -o {} {} {}'.format(self.compiler, flags,
                ofile, file, self.idirs)

        # Print command if verbose
        if verbose:
            print(colors.PURPLE + cmd + colors.RESET)
        else:
            print()

        # Unlock if available
        if lock_avaiable:
            lock.release()

        # Run command and check if it was successful
        ret = os.system(cmd)
        if ret != 0:
            return ''

        # Return object
        return ofile

    # Link source objects
    def link(self, compiled, fsources, verbose):
        # TODO: avoid repeated linking

        # Check if any of the sources failed to compile
        if len(fsources) > 0:
            # Print message
            msg = 'Failed to compile the following sources, skipping linking process'
            print(error('error', colors.FAIL, msg))

            # Print failed sources
            for file in fsources:
                print(colors.PURPLE + '\t' + file + colors.RESET)

            # Return empty for failure
            return ''

        # Command generation
        compiled_str = ' '.join(compiled)

        flags = self.linkage_flags
        if self.dynamic:
            flags += ' -shared'

        cmd = f'{self.compiler} {flags} {compiled_str} -o {self.tpath} {self.ldirs} {self.libs} {self.rpaths}'

        # Print message
        msg = f'Linking executable {self.target}... '
        print(error('info', colors.OKBLUE, msg), end='')

        # Print command if verbose
        if verbose:
            print(colors.PURPLE + cmd + colors.RESET)
        else:
            print()

        # Check return of linking
        ret = os.system(cmd)
        if ret != 0:
            # Print message and return empty
            msg = 'Failed to link '
            if self.target == 'smake-tmp-build':
                msg += 'temporary build'
            else:
                msg += 'target ' + self.target

            print(error('error', colors.FAIL, msg))
            return ''

        return self.tpath

    # Run all dependencies
    def run_dependencies(self, threads=1, verbose=False):
        for name in self.dependencies:
            build = self.config.builds[name]

            ret = None
            if threads > 1:
                ret = build.mt_run(threads, verbose)
            else:
                ret = build.run(verbose)

            if ret == '':
                return name

        return True

    # Compile all sources
    def run(self, verbose = False):
        # First, run all dependencies
        rd = self.run_dependencies(verbose=verbose)
        if rd != True:
            msg = 'Failed to run dependency ' + rd + ' for ' + self.build + ', skipping build'
            print(error('error', colors.FAIL, msg))
            return ''

        # Message that this build is running
        msg = f'Building {self.target}...'
        print('\n' + error('info', colors.OKBLUE, msg))

        # List of compiled files
        compiled = []

        # Failed sources
        fsources = []

        # Generate format string
        slen = str(len(self.sources))
        fstr = colors.OKCYAN + '[{:>' + str(len(slen)) + '}/' + slen + '] ' \
            + colors.OKGREEN + 'Compiling {}... ' + colors.RESET

        # Compile the sources
        for i in range(len(self.sources)):
            # Create the message
            msg = fstr.format(i + 1, self.sources[i])

            # Compile the file (or attempt to)
            ofile = self.compile(False, self.sources[i], msg, verbose)

            # Check failure
            if len(ofile) == 0:
                fsources.append(self.sources[i])

            # Add to compiled list
            compiled.append(ofile)

        # Link all the objects
        if self.nolink:
            return True if len(fsources) == 0 else ''

        return self.link(compiled, fsources, verbose)

    # Make lock available to processes
    def init(self, l):
        global lock
        lock = l

    # Compile multithreaded
    def mt_run(self, threads, verbose = False):
        # First, run all dependencies
        rd = self.run_dependencies(threads=threads, verbose=verbose)
        if rd != True:
            msg = 'Failed to run dependency ' + rd + ' for ' + self.build + ', skipping build'
            print(error('error', colors.FAIL, msg))
            return ''

        # Message that this build is running
        msg = f'Building {self.target}... '
        print('\n' + error('info', colors.OKBLUE, msg))

        # Interprocess lock
        lock = multiprocessing.Lock()

        # Generate format string
        slen = str(len(self.sources))
        fstr = colors.OKCYAN + '[{:>' + str(len(slen)) + '}/' + slen + '] ' \
            + colors.OKGREEN + 'Compiling {}... ' + colors.RESET

        # Create the arguments
        args = []
        for i in range(len(self.sources)):
            msg = fstr.format(i + 1, self.sources[i])
            args.append((True, self.sources[i], msg, verbose))

        with multiprocessing.Pool(threads, initializer = self.init, initargs=(lock,)) as pool:
            rets = pool.starmap(self.compile, args)

        # Compiled and failed
        compiled = []
        fsources = []

        # Check return values
        for i in range(len(rets)):
            # Check if compilation was successful
            if len(rets[i]) == 0:
                fsources.append(self.sources[i])
            else:
                compiled.append(rets[i])

        # Link the objects
        if self.nolink:
            return True if len(fsources) == 0 else ''

        return self.link(compiled, fsources, verbose)

# Target class
class Target:
    def __init__(self, name, modes, builds, postbuilds):
        self.name = name
        self.modes = modes
        self.builds = builds
        self.postbuilds = postbuilds

        # Check that each mode has a build

    # Generate compile_commands.json
    def gen_ccmds(self):
        # Return string
        ret = ''

        # Run in each build
        for build in self.builds:
            ret += self.builds[build].gen_ccmds()

        return ret

    def run(self, mode = 'default', threads = 0, verbose = False):
        # Empty is always a valid mode, but `default should be used'
        if len(mode) == 0:
            mode = 'default'

        # Retrieve run attributes
        if not (mode in self.modes):
            msg = f'Invalid mode {mode} for target {self.name}'
            print(error('error', colors.FAIL, msg))
            exit(-1)

        build = self.builds[mode]

        # Run the build
        if threads > 0:
            target = build.mt_run(threads, verbose)
        else:
            target = build.run(verbose)

        # Run postbuild with target argument, if present
        if mode in self.postbuilds:
            if len(target) == 0:
                msg = 'Failed to compile target, skipping postbuild script'
                print(error('error', colors.FAIL, msg))
                return

            postbuild = self.postbuilds[mode]
            msg = 'Sucessfully compiled target, running postbuild script'
            print(error('info', colors.OKBLUE, msg))
            postbuild.run(target)

# Global helper functions
def split_plain(elem):
    prop = elem
    if isinstance(elem, str):
        prop = prop.split(', ')
    return prop

def split(d, pr, defns):
    prop = split_plain(d[pr])

    out = []
    for i in range(len(prop)):
        if prop[i] in defns:
            value = defns[prop[i]]

            if isinstance(value, list):
                out.extend(value)
            else:
                out.append(value)
        else:
            out.append(prop[i])

    return out

def concat(ldicts):
    out = {}
    for d in ldicts:
        out.update(d)
    return out

# Config class
class Config:
    # Constructor takes no argument
    def __init__(self):
        # Initialize builds to empty
        self.builds = {}

        # Initialize targets to empty
        self.targets = {}

        # Set installations to empty
        self.installs = {}

        # Get config file from current dir
        if os.path.exists('smake.yaml'):
            self.load_file('smake.yaml')
        else:
            msg = 'No smake.yaml file found in current directory'
            print(error('error', colors.FAIL, msg))
            exit(-1)

    # Reads definitions from variables like sources, includes, etc.
    def load_definitions(self, smake):
        # TODO: error on duplicate definition

        # Output dictionary
        defns = {}

        # Load definitions
        if 'definitions' in smake:
            for dgroup in smake['definitions']:
                key, value = next(iter(dgroup.items()))
                value = split_plain(value)
                defns.update({key: value})

        # Default compiler and standard
        if 'default_compiler' in smake:
            Build.default_compiler = smake['default_compiler']

        if 'default_standard' in smake:
            Build.default_standard = smake['default_standard']

        # Return the dictionary
        return defns

    # Create a build object
    def load_build(self, build, defns):
        name = list(build)[0]
        properties = {}
        for d in build[name]:
            properties.update(d)

        # Check unused sections
        used = [
            'sources', 'idirs', 'libraries',
            'ldirs', 'flags', 'linkage_flags',
            'compiler', 'dependencies', 'options'
        ]

        for key in properties:
            if key not in used:
                msg = f'Section \"{key}\" in builds is not used by smake'
                print(error('warning', colors.WARNING, msg))

        # Preprocess properties
        if 'sources' not in properties:
            print(error('error', colors.FAIL, f'No sources specified for build \"{name}\"'))
            exit(-1)

        sources = split(properties, 'sources', defns)

        # Optional properties
        includes = []
        libraries = []
        ldirs = []
        flags = {}
        linkage_flags = []
        compiler = Build.default_compiler
        dependencies = []
        options = {
            'dll': False,
            'nolink': False,
            'output_dir': None,
            'output_format': None,
            'target_name': None
        }

        # TODO: map for these to their handlers?
        if 'idirs' in properties:
            includes = split(properties, 'idirs', defns)

        if 'libraries' in properties:
            libraries = split(properties, 'libraries', defns)

        if 'ldirs' in properties:
            ldirs = split(properties, 'ldirs', defns)

        if 'flags' in properties:
            f = properties['flags']
            if type(f) == str:
                flags = {'default' : split_plain(properties['flags'])}
            elif type(f) == list:
                for d in f:
                    k = list(d)[0]
                    v = split_plain(d[k])
                    flags.update({k : v})

        if 'linkage_flags' in properties:
            linkage_flags = split(properties, 'linkage_flags', defns)

        if 'compiler' in properties:
            compiler = properties['compiler']

        if 'dependencies' in properties:
            dependencies = split(properties, 'dependencies', defns)

        if 'options' in properties:
            options_str = properties['options']

            options_list = options_str.split(',')
            for opt in options_list:
                # Strip whitespace (leading and trailing)
                opt = opt.strip()
                if opt == 'dll':
                    options['dll'] = True
                elif opt == 'nolink':
                    options['nolink'] = True

                opt = opt.split('=')
                if opt[0] == 'output_dir':
                    options['output_dir'] = opt[1]

                if opt[0] == 'output_format':
                    options['output_format'] = opt[1]

                if opt[0] == 'target_name':
                    options['target_name'] = opt[1]

        # Create and return the object
        return Build(self, 'tmp_build_' + name, name, compiler, sources, dependencies,
            includes, libraries, ldirs, flags, linkage_flags, options)

    def load_all_builds(self, smake, defns):
        # Check that builds actually exists
        if 'builds' not in smake:
            print(colors.FAIL + 'No builds defined in smake.yaml' + colors.RESET)
            exit(-1)

        blist = {}
        for b in smake['builds']:
            name = list(b)[0]
            build = self.load_build(b, defns)
            blist.update({name: build})

        return blist

    # Check validity of postbuild
    def _check_postbuild(self, name, pr, modes):
        if type(pr) is str:
            msg = f'in target {name}: postbuilds section expects pair "mode: script"'
            print(error('error', colors.FAIL, msg))
            exit(-1)
        elif type(pr) is dict:
            k = next(iter(pr))
            if k not in modes:
                msg = f'in target {name}: postbuild \"{k}\" is not a valid mode'
                print(error('error', colors.FAIL, msg))
                exit(-1)

    def load_target(self, target, blist, defns):
        name = list(target)[0]
        properties = {}
        for d in target[name]:
            properties.update(d)

        # Warn unused sections
        used = ['modes', 'builds', 'postbuilds']
        for key in properties:
            if key not in used:
                msg = f'Section \"{key}\" in targets is not used by smake'
                print(error('warning', colors.WARNING, msg))

        # Preprocess properties
        modes = ['default']
        if 'modes' in properties:
            modes.extend(split(properties, 'modes', defns))

        # Gets builds and postbuilds
        builds = concat(properties['builds'])

        # Check that all builds are valid
        for b in builds.values():
            if b not in blist:
                msg = f'Build \"{b}\" in target \"{name}\" is not defined'
                print(error('error', colors.FAIL, msg))
                exit(-1)

        postbuilds = {}
        if 'postbuilds' in properties:
            for pr in properties['postbuilds']:
                self._check_postbuild(name, pr, modes)
            postbuilds = concat(properties['postbuilds'])

        # Preprocess predefined things
        # TODO: separate methods
        for b in builds:
            bname = builds[b]

            if bname in blist:
                builds[b] = blist[bname]
                builds[b].set_target(name)
            # TODO: errir handling here

        # If the postbuild is a string, then convert to Script
        for pe in postbuilds:
            pname = postbuilds[pe]

            if pname in defns:
                postbuilds[pe] = defns[pname]
            else:
                postbuilds[pe] = Script(pname)

        return Target(name, modes, builds, postbuilds)

    def load_all_targets(self, smake, builds, defns):
        # Check that targets exist
        if 'targets' not in smake:
            print(colors.FAIL + 'No targets specified in smake.yaml' + colors.RESET)
            exit(-1)

        tlist = {}
        for t in smake['targets']:
            name = list(t)[0]
            target = self.load_target(t, builds, defns)
            tlist.update({name: target})

        return tlist

    # Read all installs
    def load_all_installs(self, smake, defns):
        if 'installs' not in smake:
            return {}

        installs = {}
        for install in smake['installs']:
            for script in install:
                slist = split_plain(install[script])
                installs.update({script: Script(*slist)})
        return installs

    # Read config file
    def load_file(self, file):
        # Open and read the config
        with open(file, 'rt', encoding='utf8') as file:
            smake = yaml.safe_load(file)

        # Check unused sections
        # TODO: line numbers
        used = ['default_compiler', 'default_standard',
            'definitions', 'builds', 'targets', 'installs']

        for section in smake:
            if section not in used:
                msg = f'Section \"{section}\" in smake.yaml is not used by smake'
                print(error('warning', colors.WARNING, msg))

        # Load the definitions
        defns = self.load_definitions(smake)

        # Load all builds
        self.builds = self.load_all_builds(smake, defns)

        # Load all targets
        self.targets = self.load_all_targets(smake, self.builds, defns)

        # Load all installs
        self.installs = self.load_all_installs(smake, defns)

    # List all targets
    def list_targets(self):
        # Return if no targets
        if len(self.targets) == 0:
            print(colors.FAIL + 'No targets found' + colors.RESET)
            exit(-1)

        # Max string length of target name
        maxlen = 0
        for t in self.targets:
            if len(t) > maxlen:
                maxlen = len(t)

        # Padding
        maxlen += 5

        # Header message
        fmt = colors.BOLD + colors.OKCYAN + '{:<' + str(maxlen) + '}{}' + colors.RESET
        print(fmt.format('Target', 'Modes'))

        # Print the targets
        for t in self.targets:
            modes = ''
            for i in range(len(self.targets[t].modes)):
                modes += self.targets[t].modes[i]

                if i != len(self.targets[t].modes) - 1:
                    modes += ', '

            fmt = colors.OKBLUE + '{:<' + str(maxlen) + '}' + \
                colors.PURPLE + '{}' + colors.RESET
            print(fmt.format(t, modes))

    # Generate compile_commands.json
    def gen_ccmds(self):
        # Final string
        ccmds = ''

        # Run the function on all targets
        for t in self.targets:
            ccmds += self.targets[t].gen_ccmds()

        # Last preprocessing
        ccmds = '[\n' + ccmds.rstrip(',\n') + '\n]'

        # Write to file
        with open('compile_commands.json', 'w') as file:
            file.write(ccmds)

    # Run the correct target
    def run(self, target, mode = 'default', threads = 0, verbose = False):
        # Check if targets are empty
        if len(self.targets) == 0:
            msg = 'No targets found in smake'
            print(error('error', colors.FAIL, msg))
            exit(-1)

        # If the target is all, then run all targets
        if target == 'all':
            for t in self.targets:
                self.targets[t].run(mode, threads, verbose)
            return

        # Check if the target is valid
        if target in self.targets:
            self.targets[target].run(mode, threads, verbose)
        else:
            msg = f'No target {target} found.' + \
                ' Perhaps you meant one of the following:'
            print(error('error', colors.FAIL, msg))
            for valid in list(self.targets.keys()):
                print(colors.PURPLE + f'\t{valid}' + colors.RESET)

    # Run installation for a target
    def install(self, target):
        # Check presence of install target
        if target not in self.installs:
            msg = f'No install target {target} in installs section'
            print(error('error', colors.FAIL, msg))
            exit(-1)

        # Get the script and execute it
        script = self.installs[target]
        script.run('')

# Script as executable
if __name__ == '__main__':
    # Build the parser
    parser = argparse.ArgumentParser()

    # {TARGET} -m {EXECUTOR} -j{THREADS}
    parser.add_argument('target', help="Target name", nargs='?', default='all')
    parser.add_argument('-m', "--mode", help="Execution mode", default='')
    parser.add_argument('-j', "--threads",
                        help = "Number of concurrent threads", type = int, default = 0)

    # Special args
    parser.add_argument('-l', '--list', help = 'List all targets', action = 'store_true')
    parser.add_argument('-C', '--clear-cache', help = 'Clear smake cache', action = 'store_true')
    parser.add_argument('-G', '--gen-ccmds', help = 'Generate compile_commands.json', action = 'store_true')
    parser.add_argument('-v', '--verbose', help = 'Verbose mode', action = 'store_true')
    parser.add_argument('-I', '--install', help = 'Install target', action = 'store_true')

    # TODO: quiet version, which shows currently compiling thing (live progress
    # bar/ percentage)
    # TODO: option for no eexecutable (mulitple cmpiled, no exec)
    # TODO: custom smakefile type file (parse into a dictionary, compare with
    # pyyaml output)

    # Read the arguments
    args = parser.parse_args()

    # No need to read config for this
    # TODO: remove all built objects, since they are not guaranteed to all be in
    # .smake --> will need to read config
    if args.clear_cache:
        os.system('rm -rf .smake')
        exit(0)

    # Create the local config
    config = Config()

    # Create compile_commands.json
    if args.gen_ccmds:
        config.gen_ccmds()
        exit(0)

    # Run the target
    if args.list:
        config.list_targets()
    elif args.install:
        config.install(args.target)
    else:
        config.run(args.target, args.mode, args.threads, args.verbose)
