Prepping for first release.
Prepping for first release.

/*-
 * Copyright 2014 The American National Corpus
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 */
package org.anc.json.compiler

import groovy.json.JsonBuilder
import org.anc.json.compiler.Version
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.customizers.ImportCustomizer
import groovy.util.logging.Slf4j

/**
 * @author Keith Suderman
 */
@Slf4j
class SchemaCompiler {
    /** Default file extension for LAPPS schema files. */
    static final String EXTENSION = ".schema"

    /** The set of files included into this script. */
    Set<String> included = new HashSet<String>()

    /** The parent dir of the executing script.  This is used to resolve
     *  relative filenames of included scripts.
     */
    File parentDir

    /** A global Binding object shared between the executing script and
     *  included scripts.
     */
    Binding bindings = new Binding()

    /** The JSON contents generated by executing the script. */
    Map contents = [:]

    /** If true then the output will be pretty printed. Otherwise the
     *  generated JSON will be compacted.
     */
    boolean prettyPrint = false

    /** The draft version number to use when injecting the schema URI
     *  into the $schema field of the JSON schema.
     */
    int draftVersion = -1

    String compile(File file, args) {
        parentDir = file.parentFile
        return compile(file.text, args)
    }

    String compile(File file) {
        parentDir = file.parentFile
        return compile(file.text, null)
    }

    String compile(URL url) {
        return compile(url.text, null)
    }
    String compile(String source) {
        return compile(source, null)
    }

    String compile(String scriptString, args) {
        ClassLoader loader = getLoader()
        CompilerConfiguration configuration = getCompilerConfiguration()
        GroovyShell shell = new GroovyShell(loader, bindings, configuration)

        Script script = shell.parse(scriptString)
        if (args != null && args.size() > 0) {
            // Parse any command line arguments into a HashMap that will
            // be passed in to the user's script.
            def params = [:]
            args.each { arg ->
                String[] parts = arg.split('=')
                String name = parts[0].startsWith('-') ? parts[0][1..-1] : parts[0]
                String value = parts.size() > 1 ? parts[1] : Boolean.TRUE
                params[name] = value
            }
            script.binding.setVariable("args", params)
        }
        else {
            script.binding.setVariable("args", [:])
        }
        ["object", "string", "array", "number", "boolean", "integer", "null"].each { script.binding.setVariable(it, it) }
        script.binding.with {
            setVariable "nil", "null"
            setVariable "schema", '$schema'
            setVariable "ref", '$ref'
        }

        // Add the $schema to the contents before running the script so it
        // will be the first thing listed when serializing to JSON.
        if (draftVersion > 0) {
            if (contents['$schema'] == null) {
                contents['$schema'] = String.format("http://json-schema.org/draft-%02d/schema#", draftVersion)
            }
        }

        script.metaClass = getMetaClass(script.class, shell)
        try {
            // Running the DSL script creates the objects needed to generate the HTML
            script.run()
        }
        catch (Exception e) {
//            println()
//            println "Script execution threw an exception:"
//            e.printStackTrace()
//            println()
//            StringWriter writer = new StringWriter()
//            PrintWriter pwriter = new PrintWriter(writer)
//            e.printStackTrace(pwriter)
//            contents.error = e.getMessage()
//            contents.stack = writer.toString()
            contents = [:]
            contents.error = e.getMessage()
        }

        if (prettyPrint) {
            return new JsonBuilder(contents).toPrettyString()
        }
        else {
            return new JsonBuilder(contents).toString()
        }
    }

    ClassLoader getLoader() {
        ClassLoader loader = Thread.currentThread().contextClassLoader;
        if (loader == null) {
            loader = this.class.classLoader
        }
        return loader
    }

    CompilerConfiguration getCompilerConfiguration() {
        ImportCustomizer customizer = new ImportCustomizer()
        /*
         * Custom imports can be defined in the ImportCustomizer.
         * For example:
         *   customizer.addImport("org.anc.xml.Parser")
         *   customizer.addStarImports("org.anc.util")
         *
         * The jar files for any packages imported this way must be
         * declared as Maven dependencies so they will be available
         * at runtime.
         */

        CompilerConfiguration configuration = new CompilerConfiguration()
        configuration.addCompilationCustomizers(customizer)
        return configuration
    }


    MetaClass getMetaClass(Class<?> theClass, GroovyShell shell) {
        ExpandoMetaClass meta = new ExpandoMetaClass(theClass, false)
        meta.methodMissing = { String name, args ->
            log.info "Missing method {}", name
            def value = contents[name]
            if (value != null) {
                log.warn "Redefinition of {} {}", name, value
            }

            if (args == null || args.size() == 0) {
                throw new SchemaException("No parameters passed to ${name}.")
            }
            if ((args[0] instanceof Closure)) {
                log.debug "Running closure()"
                Closure cl = (Closure) args[0]
                cl.delegate = new MethodDelegate(bindings)
                cl.resolveStrategy = Closure.DELEGATE_FIRST
                cl()
                contents[name] = cl.delegate.contents
            }
            else {
                if (args.size() == 1) {
                    value = args[0]
                }
                else {
                    value = args
                }
                log.debug "Setting contents for {} to {}", name, value
                contents[name] = value
                log.debug "Contents size is {}", contents.size()
            }
        }
        meta.required = { args ->
            if (args instanceof List) {
                contents.required = args
            }
            else {
                contents.required = [ args ]
            }
        }


        meta.include = { String filename ->
            // Make sure we can find the file. The default behaviour is to
            // look in the same directory as the source script.
            // TODO Allow an absolute path to be specified.

            def filemaker
            if (parentDir != null) {
                filemaker = { String name ->
                    return new File(parentDir, name)
                }
            }
            else {
                filemaker = { String name ->
                    new File(name)
                }
            }

            File file = filemaker(filename)
            if (!file.exists() || file.isDirectory()) {
                file = filemaker(filename + EXTENSION)
                if (!file.exists()) {
                    throw new FileNotFoundException(filename)
                }
            }
            // Don't include the same file multiple times.
            if (included.contains(filename)) {
                return
            }
            included.add(filename)


            // Parse and run the script.
            Script included = shell.parse(file)
            included.metaClass = getMetaClass(included.class, shell)
            included.run()
        }


        meta.initialize()
        return meta
    }

    static void main(args) {
        def cli = new CliBuilder()
        cli.usage = "java -jar jsonc-${Version.version}.jar -[vph] [-d <3|4>] -i <input-file> [-o <output-file>]"
        cli.header = """
Generates the JSON representation of a LAPPS schema.

"""
        cli.footer = """
If no output file is provided the generated schema will be
written to System.out.

"""
        cli.v(longOpt:'version', 'displays the version number.')
        cli.h(longOpt:'help', 'displays this help message.')
        cli.i(longOpt:'input', args:1, 'the LAPPS schema file.')
        cli.o(longOpt:'output', args:1, 'the JSON file to be written.')
        cli.p(longOpt:'pretty', 'pretty print the generated schema.')
        cli.d(longOpt:'draft', args:1, 'draft version number to be injected into $schema')

        def params = cli.parse(args)
        if (!params) {
            return
        }
        if (params.h) {
            cli.usage()
            return
        }
        if (params.v) {
            println()
            println "LAPPS JSON Schema Compiler v${Version.version}"
            println "Copyright 2014 American National Corpus."
            println()
            return
        }

        SchemaCompiler compiler = new SchemaCompiler()
        if (params.p) {
            compiler.prettyPrint = true
        }
        if (params.d) {
            compiler.draftVersion = params.d as int
        }

        String json = compiler.compile(new File(params.i), null)
        if (params.o) {
            new File(params.o).text = json
        }
        else {
            println json
        }

    }

}

/*-
 * Copyright 2014 The American National Corpus
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 */
package org.anc.json.compiler

import groovy.json.JsonBuilder
import org.anc.json.compiler.Version
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.customizers.ImportCustomizer
import groovy.util.logging.Slf4j

/**
 * @author Keith Suderman
 */
@Slf4j
class SchemaCompiler {
    /** Default file extension for LAPPS schema files. */
    static final String EXTENSION = ".schema"

    /** The set of files included into this script. */
    Set<String> included = new HashSet<String>()

    /** The parent dir of the executing script.  This is used to resolve
     *  relative filenames of included scripts.
     */
    File parentDir

    /** A global Binding object shared between the executing script and
     *  included scripts.
     */
    Binding bindings = new Binding()

    /** The JSON contents generated by executing the script. */
    Map contents = [:]

    /** If true then the output will be pretty printed. Otherwise the
     *  generated JSON will be compacted.
     */
    boolean prettyPrint = false

    /** The draft version number to use when injecting the schema URI
     *  into the $schema field of the JSON schema.
     */
    int draftVersion = -1

    String compile(File file, args) {
        parentDir = file.parentFile
        return compile(file.text, args)
    }

    String compile(File file) {
        parentDir = file.parentFile
        return compile(file.text, null)
    }

    String compile(URL url) {
        return compile(url.text, null)
    }
    String compile(String source) {
        return compile(source, null)
    }

    String compile(String scriptString, args) {
        ClassLoader loader = getLoader()
        CompilerConfiguration configuration = getCompilerConfiguration()
        GroovyShell shell = new GroovyShell(loader, bindings, configuration)

        Script script = shell.parse(scriptString)
        if (args != null && args.size() > 0) {
            // Parse any command line arguments into a HashMap that will
            // be passed in to the user's script.
            def params = [:]
            args.each { arg ->
                String[] parts = arg.split('=')
                String name = parts[0].startsWith('-') ? parts[0][1..-1] : parts[0]
                String value = parts.size() > 1 ? parts[1] : Boolean.TRUE
                params[name] = value
            }
            script.binding.setVariable("args", params)
        }
        else {
            script.binding.setVariable("args", [:])
        }
        ["object", "string", "array", "number", "boolean", "integer", "null"].each { script.binding.setVariable(it, it) }
        script.binding.with {
            setVariable "nil", "null"
            setVariable "schema", '$schema'
            setVariable "ref", '$ref'
        }

        // Add the $schema to the contents before running the script so it
        // will be the first thing listed when serializing to JSON.
        if (draftVersion > 0) {
            if (contents['$schema'] == null) {
                contents['$schema'] = String.format("http://json-schema.org/draft-%02d/schema#", draftVersion)
            }
        }

        script.metaClass = getMetaClass(script.class, shell)
        try {
            // Running the DSL script creates the objects needed to generate the HTML
            script.run()
        }
        catch (Exception e) {
            println()
            println "Script execution threw an exception:"
            e.printStackTrace()
            println()
            StringWriter writer = new StringWriter()
            PrintWriter pwriter = new PrintWriter(writer)
            e.printStackTrace(pwriter)
            contents.error = e.getMessage()
            contents.stack = writer.toString()
        }

        if (prettyPrint) {
            return new JsonBuilder(contents).toPrettyString()
        }
        else {
            return new JsonBuilder(contents).toString()
        }
    }

    ClassLoader getLoader() {
        ClassLoader loader = Thread.currentThread().contextClassLoader;
        if (loader == null) {
            loader = this.class.classLoader
        }
        return loader
    }

    CompilerConfiguration getCompilerConfiguration() {
        ImportCustomizer customizer = new ImportCustomizer()
        /*
         * Custom imports can be defined in the ImportCustomizer.
         * For example:
         *   customizer.addImport("org.anc.xml.Parser")
         *   customizer.addStarImports("org.anc.util")
         *
         * The jar files for any packages imported this way must be
         * declared as Maven dependencies so they will be available
         * at runtime.
         */

        CompilerConfiguration configuration = new CompilerConfiguration()
        configuration.addCompilationCustomizers(customizer)
        return configuration
    }


    MetaClass getMetaClass(Class<?> theClass, GroovyShell shell) {
        ExpandoMetaClass meta = new ExpandoMetaClass(theClass, false)
        meta.methodMissing = { String name, args ->
            log.info "Missing method {}", name
            def value = contents[name]
            if (value != null) {
                log.warn "Redefinition of {} {}", name, value
            }

            if (args == null || args.size() == 0) {
                throw new SchemaException("No parameters passed to ${name}.")
            }
            if ((args[0] instanceof Closure)) {
                log.debug "Running closure()"
                Closure cl = (Closure) args[0]
                cl.delegate = new MethodDelegate(bindings)
                cl.resolveStrategy = Closure.DELEGATE_FIRST
                cl()
                contents[name] = cl.delegate.contents
            }
            else {
                if (args.size() == 1) {
                    value = args[0]
                }
                else {
                    value = args
                }
                log.debug "Setting contents for {} to {}", name, value
                contents[name] = value
                log.debug "Contents size is {}", contents.size()
            }
        }
        meta.required = { args ->
            if (args instanceof List) {
                contents.required = args
            }
            else {
                contents.required = [ args ]
            }
        }


        meta.include = { String filename ->
            // Make sure we can find the file. The default behaviour is to
            // look in the same directory as the source script.
            // TODO Allow an absolute path to be specified.

            def filemaker
            if (parentDir != null) {
                filemaker = { String name ->
                    return new File(parentDir, name)
                }
            }
            else {
                filemaker = { String name ->
                    new File(name)
                }
            }

            File file = filemaker(filename)
            if (!file.exists() || file.isDirectory()) {
                file = filemaker(filename + EXTENSION)
                if (!file.exists()) {
                    throw new FileNotFoundException(filename)
                }
            }
            // Don't include the same file multiple times.
            if (included.contains(filename)) {
                return
            }
            included.add(filename)


            // Parse and run the script.
            Script included = shell.parse(file)
            included.metaClass = getMetaClass(included.class, shell)
            included.run()
        }


        meta.initialize()
        return meta
    }

    static void main(args) {
        def cli = new CliBuilder()
        cli.usage = "java -jar jsonc-${Version.version}.jar -[vph] [-d <3|4>] -i <input-file> [-o <output-file>]"
        cli.header = """
Generates the JSON representation of a LAPPS schema.

"""
        cli.footer = """
If no output file is provided the generated schema will be
written to System.out.

"""
        cli.v(longOpt:'version', 'displays the version number.')
        cli.h(longOpt:'help', 'displays this help message.')
        cli.i(longOpt:'input', args:1, 'the LAPPS schema file.')
        cli.o(longOpt:'output', args:1, 'the JSON file to be written.')
        cli.p(longOpt:'pretty', 'pretty print the generated schema.')
        cli.d(longOpt:'draft', args:1, 'draft version number to be injected into $schema')

        def params = cli.parse(args)
        if (!params) {
            return
        }
        if (params.h) {
            cli.usage()
            return
        }
        if (params.v) {
            println()
            println "LAPPS JSON Schema Compiler v${Version.version}"
            println "Copyright 2014 American National Corpus."
            println()
            return
        }

        SchemaCompiler compiler = new SchemaCompiler()
        if (params.p) {
            compiler.prettyPrint = true
        }
        if (params.d) {
            compiler.draftVersion = params.d as int
        }

        String json = compiler.compile(new File(params.i), null)
        if (params.o) {
            new File(params.o).text = json
        }
        else {
            println json
        }

    }

}
