cli/CliAppUnit.js

/**
 * @module flitter-cli/CliAppUnit
 */

const Unit = require('libflitter/Unit')
const PositionalOption = require('./options/PositionalOption')
const FlagOption = require('./options/FlagOption')

/**
 * Final unit to run the ./flitter CLI utility. This will replace the default {@link module:libflitter/app/AppUnit~AppUnit}.
 */
class CliAppUnit extends Unit {
    /**
     * Defines the services required by this unit.
     * @returns {Array<string>}
     */
    static get services() {
        return [...super.services, 'cli', 'utility', 'output']
    }

    /**
     * Get the name of the service provided by this unit: 'CliApp'
     * @returns {string} - 'CliApp'
     */
    static get name() {
        return 'CliApp'
    }

    /**
     * Loads the unit. Retrieves the CLI arguments and passes control off to the appropriate {@link module:flitter-cli/Directive~Directive} handler.
     * @param {module:libflitter/app/FlitterApp~FlitterApp} app - the Flitter app
     * @param {Array<string>} [argv = process.argv.slice(2)] - the CLI argv to be interpreted
     * @returns {Promise<void>}
     */
    async go(app){
        const argv = process.argv.slice(2)
        const directives = this.cli.loaded_directives
        
        let success = false
        for ( let class_name in directives ){
            if ( argv[0] === directives[class_name].name() ){
                await this.handle_directive(app, directives[class_name], argv.slice(1))
                // const directive = new directives[class_name]()
                // await directive.handle(app, argv.slice(1))
                success = true
                break
            }
        }
        
        if ( !success ){
            const directive = new directives.UsageDirective()
            await directive.handle(app, argv.slice(1))
        }
        
    }

    /**
     * Handles an invocation of the specified directive class with the provided arguments.
     * @param {module:libflitter/app/FlitterApp~FlitterAPp} app - the Flitter app
     * @param {module:flitter-cli/Directive~Directive} DirectiveClass - the static CLASS of the directive
     * @param {Array<string>} args - the raw CLI arguments
     * @returns {Promise<void>}
     */
    async handle_directive(app, DirectiveClass, args) {
        app.make(DirectiveClass)

        // Create the option classes for the directive
        const options = DirectiveClass.options()

        if ( !options ) {
            const directive_instance = new DirectiveClass({})
            await directive_instance.handle(app, args)
            return
        }

        const option_classes = []
        options.forEach(option => {
            if ( typeof option === 'string' ) {
                // Create option from string
                option_classes.push(this.option_from_string(option))
            } else {
                // Assume the option is already created
                option_classes.push(option)
            }
        })

        if ( this.requested_usage(args) ) {
            this.output.info(`DIRECTIVE: ${DirectiveClass.name()} - ${DirectiveClass.help()}`, 0)
            const positional_arguments = option_classes.filter(Class => Class instanceof PositionalOption)
            const flag_arguments = option_classes.filter(Class => Class instanceof FlagOption)
            this.output.info('', 0)
            this.output.info(`USAGE: ${DirectiveClass.name()} ${positional_arguments.map(x => '{'+x.argument_name+'}').join(' ')}${flag_arguments.length > 0 ? ' [flag arguments]' : ''}`, 0)
            this.output.info('', 0)
            if ( positional_arguments.length > 0 ) this.output.info(`POSITIONAL ARGUMENTS:`, 0)

            for ( const option of positional_arguments ) {
                this.output.info(`    {${option.argument_name}}${option.message ? ' - '+option.message : ''}`, 0)
            }

            this.output.info('', 0)
            if ( flag_arguments.length > 0 ) this.output.info('FLAG ARGUMENTS:', 0)

            for ( const option of flag_arguments ) {
                this.output.info(`    ${option.short_flag ? option.short_flag+', ' : ''}${option.long_flag ? option.long_flag : ''}${option.argument_description ? ' {'+option.argument_description+'}' : ''}${option.message ? ' - '+option.message : ''}`, 0)
            }

        } else {
            try {
                const option_values = this.process_options(option_classes, args)
                const directive_instance = new DirectiveClass(option_values)
                await directive_instance.handle(app, args)
            } catch (e) {
                this.output.error(e.message, 0)
                if (e.requirements) {
                    for (const string of e.requirements) {
                        this.output.error('    - ' + string, 0)
                    }
                }
            }
        }
    }

    /**
     * Determines if, at any point in the arguments, the help option's short or long flag appears.
     * @param {Array<string>} args - the raw CLI args
     * @returns {boolean} - true if the help flag appeared
     */
    requested_usage(args) {
        const help_option = this.get_help_option()
        for ( const arg of args ) {
            if ( arg.trim() === help_option.long_flag || arg.trim() === help_option.short_flag ) {
                return true
            }
        }

        return false
    }

    /**
     * Get the flag option that signals help. Usually, this is named 'help'
     * and supports the flags '--help' and '-?'.
     * @returns {module:flitter-cli/options/FlagOption~FlagOption}
     */
    get_help_option() {
        return new FlagOption('--help', '-?', 'usage information about this directive')
    }

    /**
     * Process the raw CLI arguments using an array of option class instances to build
     * a mapping of option names to provided values.
     * @param {Array<module:flitter-cli/option/Option~Option>} option_classes - array of option definitions
     * @param {Array<string>} args - the raw CLI args
     * @returns {object} - the parsed options, by name
     */
    process_options(option_classes, args) {
        let positional_arguments = option_classes.filter(Class => Class instanceof PositionalOption)
        const flag_arguments = option_classes.filter(Class => Class instanceof FlagOption)
        const option_values = {}

        flag_arguments.push(this.get_help_option())

        let expecting_flag_argument = false
        let positional_flag_name = false
        for ( const value of args ) {
            if ( value.startsWith('--') ) {
                if ( expecting_flag_argument ) {
                    throw new Error('Unexpected flag argument. Expecting argument for flag: '+positional_flag_name)
                } else {
                    const flag_argument = flag_arguments.filter(x => x.long_flag === value)
                    if ( flag_argument.length < 1 ) {
                        throw new Error('Unknown flag argument: '+value)
                    } else {
                        if ( flag_argument[0].argument_description ) {
                            positional_flag_name = flag_argument[0].argument_name
                            expecting_flag_argument = true
                        } else {
                            option_values[flag_argument[0].argument_name] = true
                        }
                    }
                }
            } else if ( value.startsWith('-') ) {
                if ( expecting_flag_argument ) {
                    throw new Error('Unexpected flag argument. Expecting argument for flag: '+positional_flag_name)
                } else {
                    const flag_argument = flag_arguments.filter(x => x.short_flag === value)
                    if ( flag_argument.length < 1 ) {
                        throw new Error('Unknown flag argument: '+value)
                    } else {
                        if ( flag_argument[0].argument_description ) {
                            positional_flag_name = flag_argument[0].argument_name
                            expecting_flag_argument = true
                        } else {
                            option_values[flag_argument[0].argument_name] = true
                        }
                    }
                }
            } else if ( expecting_flag_argument ) {
                const inferred_value = this.utility.infer(value)
                const option_instance = flag_arguments.filter(x => x.argument_name === positional_flag_name)[0]
                if ( !option_instance.validate(inferred_value) ) {
                    const e = new Error('Invalid value for argument: '+positional_flag_name)
                    e.requirements = option_instance.requirement_displays
                    throw e
                }
                option_values[positional_flag_name] = inferred_value
                expecting_flag_argument = false
            } else {
                if ( positional_arguments.length < 1 ) {
                    throw new Error('Unknown positional argument: '+value)
                } else {
                    const inferred_value = this.utility.infer(value)
                    if ( !positional_arguments[0].validate(inferred_value) ) {
                        const e = new Error('Invalid value for argument: '+positional_arguments[0].argument_name)
                        e.requirements = positional_arguments[0].requirement_displays
                        throw e
                    }
                    option_values[positional_arguments[0].argument_name] = this.utility.infer(value)
                    positional_arguments = positional_arguments.slice(1)
                }
            }
        }

        if ( expecting_flag_argument ) {
            throw new Error('Missing argument for flag: '+positional_flag_name)
        }

        if ( positional_arguments.length > 0 ) {
            throw new Error('Missing required argument: '+positional_arguments[0].argument_name)
        }

        return option_values
    }

    /**
     * Create an instance of {@link module:flitter-cli/options/Option~Option}
     * based on a string definition of a particular format.
     *
     * e.g. '{file name} | canonical name of the resource to create'
     * e.g. '--push -p {value} | the value to be pushed'
     * e.g. '--force -f | do a force push'
     *
     * @param string
     * @returns {module:flitter-cli/options/FlagOption~FlagOption|module:flitter-cli/options/PositionalOption~PositionalOption}
     */
    option_from_string(string) {
        if ( string.startsWith('{') ) {
            // The string is a positional argument
            const string_parts = string.split('|').map(x => x.trim())
            const name = string_parts[0].replace(/\{|\}/g, '')
            return string_parts.length > 1 ? (new PositionalOption(name, string_parts[1])) : (new PositionalOption(name))
        } else {
            // The string is a flag argument
            const string_parts = string.split('|').map(x => x.trim())

            // Parse the flag parts first
            const has_argument = string_parts[0].indexOf('{') >= 0
            const flag_string = has_argument ? string_parts[0].substr(0, string_parts[0].indexOf('{')).trim() : string_parts[0].trim()
            const flag_parts = flag_string.split(' ')

            let long_flag = flag_parts[0].startsWith('--') ? flag_parts[0] : false
            if ( !long_flag && flag_parts.length > 1 ) {
                if ( flag_parts[1].startsWith('--') ) {
                    long_flag = flag_parts[1]
                }
            }

            let short_flag = flag_parts[0].length === 2 ? flag_parts[0] : false
            if ( !short_flag && flag_parts.length > 1 ) {
                if ( flag_parts[1].length === 2 ) {
                    short_flag = flag_parts[1]
                }
            }

            const argument_description = has_argument ? string_parts[0].substring(string_parts[0].indexOf('{')+1, string_parts[0].indexOf('}')) : false
            const description = string_parts.length > 1 ? string_parts[1] : false

            return new FlagOption(long_flag, short_flag, description, argument_description)
        }
    }
}

module.exports = exports = CliAppUnit