@sap/cds-compiler
Version:
CDS (Core Data Services) compiler and backends
643 lines (579 loc) • 20.9 kB
JavaScript
;
/**
* Create a command line option processor and define valid commands, options and parameters.
* In order to understand a command line like this:
* $ node cdsc.js -x 1 --foo toXyz -y --bar-wiz bla arg1 arg2
*
* The following definitions should be made:
*
* ```js
* const optionProcessor = createOptionProcessor();
* optionProcessor
* .help(`General help text`);
* .option('-x, --long-form <i>')
* .option(' --foo')
* optionProcessor.command('toXyz')
* .help(`Help text for command "toXyz")
* .option('-y --y-in-long-form')
* .option(' --bar-wiz <w>', { valid: ['bla', 'foo'] })
* .option('-z --name-in-cdsc', { optionName: 'nameInOptions' })
* ```
*
* Options *must* have a long form, can have at most one <param>, and optionally
* an array of valid param values as strings. Commands and param values must not
* start with '-'. The whole processor and each command may carry a help text.
* To actually parse a command line, use
* const cli = optionProcessor.processCmdLine(process.argv);
* (see below)
*/
function createOptionProcessor() {
const optionProcessor = {
commands: {},
options: {},
positionalArguments: [],
optionClashes: [],
option,
command,
positionalArgument(argumentDefinition) {
// Default positional arguments; may be overwritten by commands.
_setPositionalArguments(argumentDefinition);
return optionProcessor;
},
help,
processCmdLine,
makePositionalArgumentsOptional,
};
return optionProcessor;
/**
* API: Define a general option.
* @param {string} optString Option string describing the command line option.
* @param {object} [options] Further options such as `ignoreCase: true` and `valid: []`.
* @param {string[]} [options.valid] Valid values for the option.
* @param {string[]} [options.ignoreCase] Ignore the case for "options.valid".
* @param {string[]} [options.optionName] Name of the option after parsing CLI arguments.
* Defaults to the camelified name of the long name.
*/
function option( optString, options ) {
return _addOption(optionProcessor, optString, options);
}
/**
* API: Define the main help text (header and general options)
* @param {string} text Help text describing all options, etc.
*/
function help( text ) {
optionProcessor.helpText = text;
return optionProcessor;
}
/**
* API: Define a command
* @param {string} cmdString Command name, short and long form, e.g. 'S, toSql'
* @param {object} [cmdOptions] Optional options, e.g. `aliases`.
*/
function command( cmdString, cmdOptions = null ) {
/** @type {object} */
const cmd = {
options: {},
positionalArguments: [],
option: commandOption,
positionalArgument(argumentDefinition) {
_setPositionalArguments(argumentDefinition, cmd.positionalArguments);
return cmd;
},
help: commandHelp,
aliases: cmdOptions?.aliases,
..._parseCommandString(cmdString),
};
if (optionProcessor.commands[cmd.longName])
throw new Error(`Duplicate assignment for long command ${ cmd.longName }`);
optionProcessor.commands[cmd.longName] = cmd;
if (cmd.shortName) {
if (optionProcessor.commands[cmd.shortName])
throw new Error(`Duplicate assignment for short command ${ cmd.shortName }`);
optionProcessor.commands[cmd.shortName] = cmd;
}
for (const alias of cmdOptions?.aliases ?? [])
optionProcessor.commands[alias] = cmd;
return cmd;
// Command API: Define a command option
function commandOption( optString, options ) {
return _addOption(cmd, optString, options);
}
// Command API: Define the command help text
function commandHelp( text ) {
cmd.helpText = text;
return cmd;
}
}
/**
* Set the positional arguments to the command line processor. Instructs the processor
* to either require N positional arguments or a dynamic number (but at least one).
* Note that you can only call this function once. Only the last invocation sets
* the positional arguments.
*
* @param {string} argumentDefinition Positional arguments, e.g. '<input> <output>' or '<files...>'
* @param {object[]} argList Array, to which the parsed arguments will be added. Default is global scope.
* @private
*/
function _setPositionalArguments( argumentDefinition, argList = optionProcessor.positionalArguments ) {
if (argList.find(arg => arg.isDynamic))
throw new Error('Can\'t add positional arguments after a dynamic one');
const registeredNames = argList.map(arg => arg.name);
const args = argumentDefinition.split(' ');
for (const arg of args) {
// Remove braces, dots and camelify.
const argName = arg.replace('<', '')
.replace('>', '')
.replace('...', '')
.replace(/[ -]./g, s => s.substring(1).toUpperCase());
if (registeredNames.includes(argName))
throw new Error(`Duplicate positional argument: ${ arg }`);
if (!isParam(arg) && !isDynamicPositionalArgument(arg))
throw new Error(`Unknown positional argument syntax: ${ arg }`);
argList.push({
name: argName,
isDynamic: isDynamicPositionalArgument(arg),
required: true,
});
registeredNames.push(argName);
}
}
/**
* Internal: Define a general or command option.
* Throws if the option is already registered in the given command context.
* or in the given command.
*
* @private
* @see option()
*/
function _addOption( cmd, optString, options ) {
const cliOpt = _parseOptionString(optString, options);
Object.assign(cliOpt, options);
_addOptionName(cmd, cliOpt.longName, cliOpt);
_addOptionName(cmd, cliOpt.shortName, cliOpt);
for (const alias of cliOpt.aliases || []) {
const aliasOpt = Object.assign({ }, cliOpt, { isAlias: true });
_addOptionName(cmd, alias, aliasOpt); // use same optionName, etc. for alias
}
return cmd;
}
/**
* Internal: Add a name to the list of options.
* Throws if the option is already registered in the given command context.
* or in the given command.
*
* @private
* @see _addOption()
*/
function _addOptionName( cmd, name, opt ) {
if (!name)
return;
if (cmd.options[name]) {
throw new Error(`Duplicate assignment for option ${ name }`);
}
else if (optionProcessor.options[name]) {
// This path is only taken if optString is for commands
optionProcessor.optionClashes.push({
option: name,
description: `Command '${ cmd.longName }' has option clash with general options for: ${ name }`,
});
}
cmd.options[name] = opt;
}
// Internal: Parse one command string like "F, toFoo". Return an object like this
// {
// longName: 'toFoo',
// shortName: 'F',
// }
function _parseCommandString( cmdString ) {
let longName;
let shortName;
const tokens = cmdString.trim().split(/, */);
switch (tokens.length) {
case 1:
// Must be "toFoo"
longName = tokens[0];
break;
case 2:
// Must be "F, toFoo"
shortName = tokens[0];
longName = tokens[1];
break;
default:
throw new Error(`Invalid command description: ${ cmdString }`);
}
return {
longName,
shortName,
};
}
// Internal: Parse one option string like "-f, --foo-bar <p>". Returns an object like this
// {
// longName: '--foo-bar',
// shortName: '-f',
// optionName: 'fooBar', // or options.optionName if provided
// param: '<p>'
// valid
// }
function _parseOptionString( optString, options ) {
let longName;
let shortName;
let param;
// split at spaces (with optional preceding comma)
const tokens = optString.trim().split(/,? +/);
switch (tokens.length) {
case 1:
// Must be "--foo"
if (isLongOption(tokens[0]))
longName = tokens[0];
break;
case 2:
// Could be "--foo <bar>", or "-f --foo"
if (isLongOption(tokens[0]) && isParam(tokens[1])) {
longName = tokens[0];
param = tokens[1];
}
else if (isShortOption(tokens[0]) && isLongOption(tokens[1])) {
shortName = tokens[0];
longName = tokens[1];
}
break;
case 3:
// Must be "-f --foo <bar>"
if (isShortOption(tokens[0]) && isLongOption(tokens[1]) && isParam(tokens[2])) {
shortName = tokens[0];
longName = tokens[1];
param = tokens[2];
}
break;
default:
throw new Error(`Invalid option description, too many tokens: ${ optString }`);
}
if (!longName)
throw new Error(`Invalid option description, missing long name: ${ optString }`);
if (!param && options?.valid)
throw new Error(`Option description has valid values but no param: ${ optString }`);
if (options?.valid) {
options.valid.forEach((value) => {
if (typeof value !== 'string')
throw new Error(`Valid values must be of type string: ${ optString }`);
});
}
return {
longName,
shortName,
optionName: options?.option ?? camelifyLongOption(longName),
param,
valid: options?.valid,
isAlias: false, // default
};
}
function makePositionalArgumentsOptional() {
for (const arg of optionProcessor.positionalArguments || [])
arg.required = false;
for (const cmd in optionProcessor.commands) {
for (const arg of optionProcessor.commands[cmd].positionalArguments || [])
arg.required = false;
}
}
// API: Let the option processor digest a command line 'argv'
// The expectation is to get a commandline like this:
// $ node cdsc.js -x 1 --foo toXyz -y --bar-wiz bla arg1 arg2
// Ignore: ^^^^^^^^^^^^
// General options: ----^^^^^^^^^^
// Command: -----------------------^^^^^
// Command options: ---------------------^^^^^^^^^^^^^^^^
// Arguments: ------------------------------------------- ^^^^^^^^^
// Expect everything that starts with '-' to be an option, up to '--'.
// Be tolerant regarding option placement: General options may also occur
// after the command (but command options must not occur before the command).
// Options may also appear after arguments. Report errors and resolve conflicts
// under the assumption that placement was correct.
// The return object should look like this:
// {
// command: 'toXyz'
// options: {
// xInLongForm: 1,
// foo: true,
// toXyz: {
// yInLongForm: true,
// barWiz: 'bla',
// }
// },
// unknownOptions: [],
// args: {
// length: 4,
// foo: 'value1',
// bar: [ 'value2', 'value3', 'value4' ]
// },
// cmdErrors: [],
// errors: [],
// }
function processCmdLine( argv ) {
const result = {
command: undefined,
options: { },
unknownOptions: [],
args: {
length: 0,
},
cmdErrors: [],
errors: [],
};
// Iterate command line
let seenDashDash = false;
// 0: "node", 1: filename
for (let i = 2; i < argv.length; i++) {
let arg = argv[i];
// To be compatible with NPM arguments, we need to support `--arg=val` as well.
if (arg.includes('=')) {
argv = [ ...argv.slice(0, i), ...arg.split('='), ...argv.slice(i + 1) ];
arg = argv[i];
}
if (arg === '--') {
// No more options after '--'
seenDashDash = true;
}
else if (!seenDashDash && arg.startsWith('--')) {
i += processOption(i);
}
else if (!seenDashDash && arg.startsWith('-')) {
splitSingleLetterOption(argv, i); // `-ab` -> `-a -b`
i += processOption(i);
}
else if (result.command === undefined) { // Command or arg
if (optionProcessor.commands[arg]) {
// Found as command
result.command = optionProcessor.commands[arg].longName;
result.options[result.command] = {};
}
else {
// Not found as command, take as arg and stop looking for commands
processPositionalArgument(arg);
result.command = null;
}
}
else {
processPositionalArgument(arg);
}
}
// Avoid 'toXyz: {}' for command without options
if (result.command && Object.keys(result.options[result.command]).length === 0)
delete result.options[result.command];
// Complain about first missing positional arguments
const missingArg = getCurrentPositionArguments().find(arg => arg.required && !result.args[arg.name]);
if (missingArg) {
const forCommand = result.command ? ` for '${ result.command }'` : '';
const errorMsg = `Missing positional argument${ forCommand }: <${ missingArg.name }${ missingArg.isDynamic ? '...' : '' }>`;
if (forCommand)
result.cmdErrors.push(errorMsg);
else
result.errors.push(errorMsg);
}
return result;
/**
* Specific commands may have custom positional arguments.
* If the current one does, use it instead of the defaults.
*
* @returns {object[]} Array of positional argument configurations.
*/
function getCurrentPositionArguments() {
const cmd = optionProcessor.commands[result.command];
return ( cmd && cmd.positionalArguments && cmd.positionalArguments.length )
? cmd.positionalArguments
: optionProcessor.positionalArguments;
}
function processPositionalArgument( argumentValue ) {
const argList = getCurrentPositionArguments();
if ( result.args.length === 0 && argList.length === 0 )
return;
const inBounds = result.args.length < argList.length;
const lastIndex = inBounds ? result.args.length : argList.length - 1;
const nextUnsetArgument = argList[lastIndex];
if (!inBounds && !nextUnsetArgument.isDynamic) {
if (result.command)
result.errors.push(`Too many arguments. '${ result.command }' expects ${ argList.length }`);
else
result.errors.push(`Too many arguments. Expected ${ argList.length }`);
return;
}
result.args.length += 1;
if (nextUnsetArgument.isDynamic) {
result.args[nextUnsetArgument.name] = result.args[nextUnsetArgument.name] || [];
result.args[nextUnsetArgument.name].push(argumentValue);
}
else {
result.args[nextUnsetArgument.name] = argumentValue;
}
}
// (Note that this works on 'argv' and 'result' from above).
// Process 'argv[i]' as an option.
// Check the option definition to see if a parameter is expected.
// If so, take it (complain if one is found in 'argv').
// Populate 'result.options' with the result. Return the number params found (0 or 1).
function processOption( i ) {
const arg = argv[i];
let currentCommand = result.command;
// First check top-level options
let currentOption = optionProcessor.options[arg];
if (currentCommand) {
// If there is a command and it has an option that overrides it, use it instead.
const cmdOpt = optionProcessor.commands[currentCommand].options[arg];
if (cmdOpt)
currentOption = cmdOpt;
else if (currentOption)
// Otherwise, if there exist a top-level option, set 'command' to null.
currentCommand = null;
}
if (!currentOption)
return reportUnknown();
if (!currentOption.param) {
setCurrentOption(true);
return 0;
}
const param = paramForOption(currentOption);
if (param === null)
return 0;
setCurrentOption(param);
return 1;
/**
* Report that an option is unknown. If the option exists for other
* commands or if the next argument looks like a param, return 1,
* otherwise 0, indicating how many argv fields have been consumed.
*
* @returns {number}
*/
function reportUnknown() {
if (currentCommand)
result.unknownOptions.push(`Unknown option "${ arg }" for the command "${ currentCommand }"`);
else
result.unknownOptions.push(`Unknown option "${ arg }"`);
if (currentCommand) {
// Not found at all. We dig into the other cdsc commands in order to check if
// the option expects a parameter and if so to take the next argument as a value
const otherCmd = Object.keys(optionProcessor.commands).find(cmd => optionProcessor.commands[cmd].options[arg]);
const otherCmdOpt = otherCmd && optionProcessor.commands[otherCmd].options[arg];
if (otherCmdOpt && hasParamForUnknown(otherCmdOpt))
return 1;
}
if (hasParamForUnknown(null))
return 1;
return 0;
}
function setCurrentOption( val ) {
if (currentCommand) {
if (!result.options[currentCommand])
result.options[currentCommand] = {};
result.options[currentCommand][currentOption.optionName] = val;
}
else {
result.options[currentOption.optionName] = val;
}
}
function reportMissingParam( opt ) {
const short = opt.shortName ? `${ opt.shortName }, ` : '';
let error = `Missing param "${ opt.param }" for option "${ short }${ opt.longName }"`;
if (currentCommand) {
error = `${ error } of command "${ currentCommand }"`;
result.cmdErrors.push(error);
}
else {
result.errors.push(error);
}
}
function reportInvalidValue( opt, value ) {
const shortOption = opt.shortName ? `${ opt.shortName }, ` : '';
const errors = currentCommand ? result.cmdErrors : result.errors;
errors.push(`Invalid value "${ value }" for option "${ shortOption }${ opt.longName }" - use one of [${ opt.valid }]`);
}
/**
* Get the value for the option's parameter. If the option does not require one,
* returns `null`. Reports missing parameters and invalid values.
*
* @returns {null|*}
*/
function paramForOption( opt, reportMissing = true ) {
if (i + 1 >= argv.length || argv[i + 1].startsWith('-')) {
if (reportMissing)
reportMissingParam(opt);
return null;
}
const value = argv[i + 1];
if (!isValidOptionValue(opt, value) && reportMissing)
reportInvalidValue(opt, value);
return value;
}
/**
* Returns true if:
* - we didn't find an option (opt === null) _or_
* - we found an option and it requires a param
* _and_ if the next arg looks like an argument.
*
* @param {object|null} opt
* @returns {boolean}
*/
function hasParamForUnknown( opt ) {
return ((!opt || opt.param) && (i + 1) < argv.length && !argv[i + 1].match('(^[.-])|[.](csn|cdl|cds|json)$'));
}
}
}
function isValidOptionValue( opt, value ) {
// Explicitly convert to string, input 'value' may be boolean
value = String(value);
if (!opt.valid?.length)
return true;
if (opt.ignoreCase)
return opt.valid.some( valid => valid.toLowerCase() === value.toLowerCase() );
return opt.valid.includes(value);
}
}
/**
* Splits `-abc` into `-a -b -c`. Does this in-place on argv.
*
* @param {string[]} argv Argument array
* @param {number} i Current option index.
*/
function splitSingleLetterOption( argv, i ) {
const arg = argv[i];
if (arg.length > 2) { // must be at least `-ab`.
const rest = argv.slice(i + 1);
argv.length = i; // trim array
argv.push(...arg.split('').slice(1).map(a => `-${ a }`), ...rest);
}
}
/**
* Return a camelCase name "fooBar" for a long option "--foo-bar"
*/
function camelifyLongOption( opt ) {
return opt.substring(2).replace(/-./g, s => s.substring(1).toUpperCase());
}
/**
* Check if 'opt' looks like a "-f" short option
*/
function isShortOption( opt ) {
return /^-[a-zA-Z?]$/.test(opt);
}
/**
* Check if 'opt' looks like a "--foo-bar" long option
*/
function isLongOption( opt ) {
return /^--[a-zA-Z0-9-]+$/.test(opt);
}
/**
* Check if 'opt' looks like a "<foobar>" parameter
*/
function isParam( opt ) {
return /^<[a-zA-Z-]+>$/.test(opt);
}
/**
* Check if 'arg' looks like "<foobar...>"
*/
function isDynamicPositionalArgument( arg ) {
return /^<[a-zA-Z-]+[.]{3}>$/.test(arg);
}
module.exports = {
createOptionProcessor,
isShortOption,
isLongOption,
isParam,
isDynamicPositionalArgument,
};