Home Reference Source Repository

src/cli/helpers.js

import 'source-map-support/register';

import chalk from 'chalk';
import { isPlainObject, isBoolean, isString, set, difference } from 'lodash';
import resolve from 'resolve';
import leven from 'leven';
import trimNewlines from 'trim-newlines';
import redent from 'redent';

import { merge } from '../configuration';
import buildDocumentationObject from '../documentation/build-documentation-object';
import generateTable from '../documentation/generate-table';
import { getDefaultValue } from '../documentation/helpers';
import { fileExists, getRocDependencies, getPackageJson } from '../helpers';
import { throwError } from '../validation';
import { isValid } from '../validation';
import { warning, importantLabel, errorLabel, warningLabel } from '../helpers/style';

/**
 * Builds the complete configuration objects.
 *
 * @param {boolean} debug - If debug mode should be enabled, logs some extra information.
 * @param {rocConfig} config - The base configuration.
 * @param {rocMetaConfig} meta - The base meta configuration.
 * @param {rocConfig} newConfig - The new configuration to base the merge on.
 * @param {rocMetaConfig} newMeta - The new meta configuration to base the merge on.
 * @param {string} [directory=process.cwd()] - The directory to resolve relative paths from.
 * @param {boolean} [validate=true] - If the newConfig and the newMeta structure should be validated.
 *
 * @returns {Object} - The result of with the built configurations.
 * @property {rocConfig} extensionConfig - The extensions merged configurations
 * @property {rocConfig} config - The final configuration, with application configuration.
 * @property {rocMetaConfig} meta - The merged meta configuration.
 */
export function buildCompleteConfig(
    debug, config = {}, meta = {}, newConfig = {}, newMeta = {}, directory = process.cwd(), validate = true
) {
    let finalConfig = { ...config };
    let finalMeta = { ...meta };

    let usedExtensions = [];
    const mergeExtension = (extensionName) => {
        const { baseConfig, metaConfig = {} } = getExtension(extensionName, directory);

        if (baseConfig) {
            usedExtensions.push(extensionName);
            finalConfig = merge(finalConfig, baseConfig);
            finalMeta = merge(finalMeta, metaConfig);
        }
    };

    if (fileExists('package.json', directory)) {
        // If extensions are defined we will use them to merge the configurations
        if (newConfig.extensions && newConfig.extensions.length) {
            newConfig.extensions.forEach(mergeExtension);
        } else {
            const packageJson = getPackageJson(directory);
            getRocDependencies(packageJson)
                .forEach(mergeExtension);
        }

        if (usedExtensions.length && debug) {
            console.log(importantLabel('The following Roc extensions will be used:'), usedExtensions, '\n');
        }

        // Check for a mismatch between application configuration and extensions.
        if (validate) {
            if (Object.keys(newConfig).length) {
                console.log(validateConfigurationStructure(finalConfig, newConfig));
            }
            if (Object.keys(newMeta).length) {
                console.log(validateConfigurationStructure(finalMeta, newMeta));
            }
        }
    }

    return {
        extensionConfig: finalConfig,
        config: merge(finalConfig, newConfig),
        meta: merge(finalMeta, newMeta)
    };
}

function getExtension(extensionName, directory) {
    try {
        const { baseConfig, metaConfig } = require(resolve.sync(extensionName, { basedir: directory }));
        return { baseConfig, metaConfig };
    } catch (err) {
        console.log(
            errorLabel(
                'Failed to load Roc extension ' + chalk.bold(extensionName) + '. ' +
                'Make sure you have it installed. Try running:'
            ) + ' ' +
            chalk.underline('npm install --save ' + extensionName)
        , '\n');
        return {};
    }
}

function validateConfigurationStructure(config, applicationConfig) {
    const getKeys = (obj, oldPath = '', allKeys = []) => {
        Object.keys(obj).forEach((key) => {
            const value = obj[key];
            const newPath = oldPath + key;

            if (isPlainObject(value)) {
                getKeys(value, newPath + '.', allKeys);
            } else {
                allKeys.push(newPath);
            }
        });

        return allKeys;
    };
    const info = [];
    const keys = getKeys(config);
    const diff = difference(getKeys(applicationConfig), keys);
    if (diff.length > 0) {
        info.push(errorLabel('Configuration problem') +
            ' There was a mismatch in the application configuration structure, make sure this is correct.\n');
        info.push(getSuggestions(diff, keys));
        info.push('');
    }
    // }
    return info.join('\n');
}

/**
 * Will create a string with suggestions for possible typos.
 *
 * @param {string[]} current - The current values that might be incorrect.
 * @param {string[]} possible - All the possible correct values.
 * @param {boolean} [command=false] - If the suggestion should be managed as a command.
 *
 * @returns {string} - A string with possible suggestions for typos.
 */
export function getSuggestions(current, possible, command = false) {
    const info = [];

    current.forEach((currentKey) => {
        let shortest = 0;
        let closest;

        for (let key of possible) {
            let distance = leven(currentKey, key);

            if (distance <= 0 || distance > 4) {
                continue;
            }

            if (shortest && distance >= shortest) {
                continue;
            }

            closest = key;
            shortest = distance;
        }

        const extra = command ? '--' : '';
        if (closest) {
            info.push('Did not understand ' + chalk.underline(extra + currentKey) +
                ' - Did you mean ' + chalk.underline(extra + closest));
        } else {
            info.push('Did not understand ' + chalk.underline(extra + currentKey));
        }
    });

    return info.join('\n');
}

/**
 * Generates a string with information about all the possible commands.
 *
 * @param {rocConfig} commands - The Roc config object, uses commands from it.
 * @param {rocMetaConfig} commandsmeta - The Roc meta config object, uses commands from it.
 *
 * @returns {string} - A string with documentation based on the available commands.
 */
export function generateCommandsDocumentation({ commands }, { commands: commandsMeta }) {
    const header = {
        name: true,
        description: true
    };

    const noCommands = {'No commands available.': ''};
    commandsMeta = commandsMeta || {};

    let body = [{
        name: 'Commands',
        objects: Object.keys(commands || noCommands).map((command) => {
            const options = commandsMeta[command] ?
                ' ' + getCommandOptionsAsString(commandsMeta[command]) :
                '';
            const description = commandsMeta[command] && commandsMeta[command].description ?
                commandsMeta[command].description :
                '';

            return {
                name: (command + options),
                description
            };
        })
    }];

    return generateCommandDocsHelper(body, header, 'Options', 'name');
}

function getCommandOptionsAsString(command = {}) {
    let options = '';
    (command.options || []).forEach((option) => {
        options += option.required ? `<${option.name}> ` : `[${option.name}] `;
    });

    return options;
}

 /**
  * Generates a string with information about a specific command.
  *
  * @param {rocConfig} settings - The Roc config object, uses settings from it.
  * @param {rocMetaConfig} commands+meta - The Roc meta config object, uses commands and settings from it.
  * @param {string} command - The selected command.
  * @param {string} name - The name of the cli.
  *
  * @returns {string} - A string with documentation based on the selected commands.
  */
export function generateCommandDocumentation({ settings }, { commands = {}, settings: meta }, command, name) {
    const rows = [];
    rows.push('Usage: ' + name + ' ' + command + ' ' + getCommandOptionsAsString(commands[command]));
    rows.push('');

    if (commands[command] && commands[command].help) {
        rows.push(redent(trimNewlines(commands[command].help)));
        rows.push('');
    }

    let body = [];

    // Generate the options table
    if (commands[command] && commands[command].settings) {
        rows.push('Options:');
        rows.push('');

        const filter = commands[command].settings === true ? [] : commands[command].settings;

        body = buildDocumentationObject(settings, meta, filter);
    }

    const header = {
        cli: true,
        description: {
            name: 'Description',
            padding: false
        },
        defaultValue: {
            name: 'Default',
            renderer: (input) => {
                input = getDefaultValue(input);

                if (input === undefined) {
                    return '';
                }

                if (!input) {
                    return warning('No default value');
                }

                return chalk.cyan(input);
            }
        }
    };

    rows.push(generateCommandDocsHelper(body, header, 'CLI options', 'cli'));

    return rows.join('\n');
}

function generateCommandDocsHelper(body, header, options, name) {
    body.push({
        name: options,
        objects: [{
            [name]: '-h, --help',
            description: 'Output usage information.'
        }, {
            [name]: '-v, --version',
            description: 'Output version number.'
        }, {
            [name]: '-d, --debug',
            description: 'Enable debug mode.'
        }, {
            [name]: '-c, --config',
            description: `Path to configuration file, will default to ${chalk.bold('roc.config.js')} in current ` +
                `working directory.`
        }, {
            [name]: '-D, --directory',
            description: 'Path to working directory, will default to the current working directory. Can be either ' +
                'absolute or relative.'
        }]
    });

    return generateTable(body, header, {
        compact: true,
        titleWrapper: (input) => input + ':',
        cellDivider: '',
        rowWrapper: (input) => `${input}`,
        header: false,
        groupTitleWrapper: (input) => input + ':'
    });
}

/**
 * Parses options and validates them.
 *
 * @param {string} command - The command to parse options for.
 * @param {Object} commands - commands from {@link rocMetaConfig}.
 * @param {Object[]} options - Options parsed by minimist.
 *
 * @returns {Object} - Parsed options.
 * @property {object[]} options - The parsed options that was matched against the meta configuration for the command.
 * @property {object[]} rest - The rest of the options that could not be matched against the configuration.
 */
export function parseOptions(command, commands, options) {
    // If the command supports options
    if (commands[command] && commands[command].options) {
        let parsedOptions = {};
        commands[command].options.forEach((option, index) => {
            const value = options[index];

            if (option.required && !value) {
                throw new Error(`Required option "${option.name}" was not provided.`);
            }

            if (value && option.validation) {
                const validationResult = isValid(value, option.validation);
                if (validationResult !== true) {
                    try {
                        throwError(option.name, validationResult, value, 'option');
                    } catch (err) {
                        /* eslint-disable no-process-exit, no-console */
                        console.log(errorLabel('Arguments problem') + ' An option was not valid.\n');
                        console.log(err.message);
                        process.exit(1);
                        /* eslint-enable */
                    }
                }
            }

            parsedOptions[option.name] = value;
        });

        return {
            options: parsedOptions,
            rest: options.splice(Object.keys(parsedOptions).length)
        };
    }

    return {
        options: undefined,
        rest: options
    };
}

/**
 * Creates mappings between cli commands to their "path" in the configuration structure, their validator and type
 * convertor.
 *
 * @param {rocDocumentationObject} documentationObject - Documentation object to create mappings for.
 *
 * @returns {Object} - Properties are the cli command without leading dashes that maps to a {@link rocMapObject}.
 */
export function getMappings(documentationObject) {
    const recursiveHelper = (groups) => {
        let mappings = {};

        groups.forEach((group) => {
            group.objects.forEach((element) => {
                // Remove the two dashes in the beginning to match correctly
                mappings[element.cli.substr(2)] = {
                    name: element.cli,
                    path: element.path,
                    convertor: getConvertor(element.defaultValue, element.cli),
                    validator: element.validator
                };
            });

            mappings = Object.assign({}, mappings, recursiveHelper(group.children));
        });

        return mappings;
    };

    return recursiveHelper(documentationObject);
}

// Convert values based on their default value
function getConvertor(value, name) {
    if (isBoolean(value)) {
        return (input) => {
            if (isBoolean(input)) {
                return input;
            }
            if (input === 'true' || input === 'false') {
                return input === 'true';
            }

            console.log(
                warningLabel(`Invalid value given for ${chalk.bold(name)}.`),
                `Will use the default ${chalk.bold(value)}.`
            );

            return value;
        };
    } else if (Array.isArray(value)) {
        return (input) => {
            let parsed;
            try {
                parsed = JSON.parse(input);
            } catch (err) {
                // Ignore this case
            }

            if (Array.isArray(parsed)) {
                return parsed;
            }

            return input.toString().split(',');
        };
    } else if (Number.isInteger(value)) {
        return (input) => parseInt(input, 10);
    } else if (!isString(value) && (!value || Object.keys(value).length === 0)) {
        return (input) => JSON.parse(input);
    }

    return (input) => input;
}

/**
 * Converts a set of arguments to {@link rocConfigSettings} object.
 *
 * @param {Object} args - Arguments parsed from minimist.
 * @param {Object} mappings - Result from {@link getMappings}.
 *
 * @returns {Object} - The mapped Roc configuration settings object.
 */
export function parseArguments(args, mappings) {
    const config = {};
    const info = [];

    Object.keys(args).forEach((key) => {
        if (mappings[key]) {
            const value = convert(args[key], mappings[key]);
            set(config, mappings[key].path, value);
        } else {
            // We did not find a match
            info.push(getSuggestions([key], Object.keys(mappings), true));
        }
    });

    if (info.length > 0) {
        console.log(errorLabel('CLI problem'), 'Some commands were not understood.\n');
        console.log(info.join('\n') + '\n');
    }

    return config;
}

function convert(value, mapping) {
    const val = mapping.convertor(value);
    const validationResult = isValid(val, mapping.validator);
    if (validationResult === true) {
        return val;
    }

    console.log(
        warning(`There was a problem when trying to automatically convert ${chalk.bold(mapping.name)}. This ` +
        `value will be ignored.`)
    );
    console.log(
        `Received ${chalk.underline(value)} and it was converted to ${chalk.underline(val)}.`, validationResult, '\n'
    );
}