#!/usr/bin/env python
#
# Merge multiple JavaScript source code files into one.
#
# Usage:
# This script requires source files to have dependencies specified in them.
#
# Dependencies are specified with a comment of the form:
#
#     // @requires <file path>
#
#  e.g.
#
#    // @requires Geo/DataSource.js
#
# This script should be executed like so:
#
#     mergejs.py <output.js> <directory> [...]
#
# e.g.
#
#     mergejs.py openlayers.js Geo/ CrossBrowser/
#
#  This example will cause the script to walk the `Geo` and
#  `CrossBrowser` directories--and subdirectories thereof--and import
#  all `*.js` files encountered. The dependency declarations will be extracted
#  and then the source code from imported files will be output to 
#  a file named `openlayers.js` in an order which fulfils the dependencies
#  specified.
#
#
# Note: This is a very rough initial version of this code.
#
# -- Copyright 2005-2013 OpenLayers contributors / OpenLayers project --
#

# TODO: Allow files to be excluded. e.g. `Crossbrowser/DebugMode.js`?
# TODO: Report error when dependency can not be found rather than KeyError.

import re
import os
import sys

SUFFIX_JAVASCRIPT = ".js"

RE_REQUIRE = "@requires?:?\s+(\S*)\s*\n" # TODO: Ensure in comment?

class MissingImport(Exception):
    """Exception raised when a listed import is not found in the lib."""

class SourceFile:
    """
    Represents a Javascript source code file.
    """

    def __init__(self, filepath, source, cfgExclude):
        """
        """
        self.filepath = filepath
        self.source = source

        self.excludedFiles = [] 
        self.requiredFiles = [] 
        auxReq = re.findall(RE_REQUIRE, self.source) 
        for filename in auxReq: 
            if undesired(filename, cfgExclude): 
                self.excludedFiles.append(filename) 
            else: 
                self.requiredFiles.append(filename) 

        self.requiredBy = []


    def _getRequirements(self):
        """
        Extracts the dependencies specified in the source code and returns
        a list of them.
        """
        return self.requiredFiles

    requires = property(fget=_getRequirements, doc="")



def usage(filename):
    """
    Displays a usage message.
    """
    print "%s [-c <config file>] <output.js> <directory> [...]" % filename


class Config:
    """
    Represents a parsed configuration file.

    A configuration file should be of the following form:

        [first]
        3rd/prototype.js
        core/application.js
        core/params.js
        # A comment

        [last]
        core/api.js # Another comment

        [exclude]
        3rd/logger.js
        exclude/this/dir

    All headings are required.

    The files listed in the `first` section will be forced to load
    *before* all other files (in the order listed). The files in `last`
    section will be forced to load *after* all the other files (in the
    order listed).

    The files list in the `exclude` section will not be imported.

    Any text appearing after a # symbol indicates a comment.
    
    """

    def __init__(self, filename):
        """
        Parses the content of the named file and stores the values.
        """
        lines = [re.sub("#.*?$", "", line).strip() # Assumes end-of-line character is present
                 for line in open(filename)
                 if line.strip() and not line.strip().startswith("#")] # Skip blank lines and comments

        self.forceFirst = lines[lines.index("[first]") + 1:lines.index("[last]")]

        self.forceLast = lines[lines.index("[last]") + 1:lines.index("[include]")]
        self.include =  lines[lines.index("[include]") + 1:lines.index("[exclude]")]
        self.exclude =  lines[lines.index("[exclude]") + 1:]

def undesired(filepath, excludes):
    # exclude file if listed
    exclude = filepath in excludes
    if not exclude:
        # check if directory is listed
        for excludepath in excludes:
            if not excludepath.endswith("/"):
                excludepath += "/"
            if filepath.startswith(excludepath):
                exclude = True
                break
    return exclude


def getNames (sourceDirectory, configFile = None):
    return run(sourceDirectory, None, configFile, True)
            

def run (sourceDirectory, outputFilename = None, configFile = None,
                                                returnAsListOfNames = False):
    cfg = None
    if configFile:
        cfg = Config(configFile)

    allFiles = []

    ## Find all the Javascript source files
    for root, dirs, files in os.walk(sourceDirectory):
        for filename in files:
            if filename.endswith(SUFFIX_JAVASCRIPT) and not filename.startswith("."):
                filepath = os.path.join(root, filename)[len(sourceDirectory)+1:]
                filepath = filepath.replace("\\", "/")
                if cfg and cfg.include:
                    if filepath in cfg.include or filepath in cfg.forceFirst:
                        allFiles.append(filepath)
                elif (not cfg) or (not undesired(filepath, cfg.exclude)):
                    allFiles.append(filepath)

    ## Header inserted at the start of each file in the output
    HEADER = "/* " + "=" * 70 + "\n    %s\n" + "   " + "=" * 70 + " */\n\n"

    files = {}

    ## Import file source code
    ## TODO: Do import when we walk the directories above?
    for filepath in allFiles:
        print "Importing: %s" % filepath
        fullpath = os.path.join(sourceDirectory, filepath).strip()
        content = open(fullpath, "U").read() # TODO: Ensure end of line @ EOF?
        files[filepath] = SourceFile(filepath, content, cfg.exclude) # TODO: Chop path?

    print

    from toposort import toposort

    complete = False
    resolution_pass = 1

    while not complete:
        complete = True

        ## Resolve the dependencies
        print "Resolution pass %s... " % resolution_pass
        resolution_pass += 1 

        for filepath, info in files.items():
            for path in info.requires:
                if not files.has_key(path):
                    complete = False
                    fullpath = os.path.join(sourceDirectory, path).strip()
                    if os.path.exists(fullpath):
                        print "Importing: %s" % path
                        content = open(fullpath, "U").read() # TODO: Ensure end of line @ EOF?
                        files[path] = SourceFile(path, content, cfg.exclude) # TODO: Chop path?
                    else:
                        raise MissingImport("File '%s' not found (required by '%s')." % (path, filepath))
        
    # create dictionary of dependencies
    dependencies = {}
    for filepath, info in files.items():
        dependencies[filepath] = info.requires

    print "Sorting..."
    order = toposort(dependencies) #[x for x in toposort(dependencies)]

    ## Move forced first and last files to the required position
    if cfg:
        print "Re-ordering files..."
        order = cfg.forceFirst + [item
                     for item in order
                     if ((item not in cfg.forceFirst) and
                         (item not in cfg.forceLast))] + cfg.forceLast
    
    print
    ## Output the files in the determined order
    result = []

    # Return as a list of filenames
    if returnAsListOfNames:
        for fp in order:
            fName = os.path.normpath(os.path.join(sourceDirectory, fp)).replace("\\","/")
            print "Append: ", fName
            f = files[fp]
            for fExclude in f.excludedFiles: 
                print "  Required file \"%s\" is excluded." % fExclude 
            result.append(fName)
        print "\nTotal files: %d " % len(result)
        return result
        
    # Return as merged source code
    for fp in order:
        f = files[fp]
        print "Exporting: ", f.filepath
        for fExclude in f.excludedFiles: 
            print "  Required file \"%s\" is excluded." % fExclude 
        result.append(HEADER % f.filepath)
        source = f.source
        result.append(source)
        if not source.endswith("\n"):
            result.append("\n")

    print "\nTotal files merged: %d " % len(files)

    if outputFilename:
        print "\nGenerating: %s" % (outputFilename)
        open(outputFilename, "w").write("".join(result))
    return "".join(result)

if __name__ == "__main__":
    import getopt

    options, args = getopt.getopt(sys.argv[1:], "-c:")
    
    try:
        outputFilename = args[0]
    except IndexError:
        usage(sys.argv[0])
        raise SystemExit
    else:
        sourceDirectory = args[1]
        if not sourceDirectory:
            usage(sys.argv[0])
            raise SystemExit

    configFile = None
    if options and options[0][0] == "-c":
        configFile = options[0][1]
        print "Parsing configuration file: %s" % filename

    run( sourceDirectory, outputFilename, configFile )
