option.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. const { InvalidArgumentError } = require('./error.js');
  2. class Option {
  3. /**
  4. * Initialize a new `Option` with the given `flags` and `description`.
  5. *
  6. * @param {string} flags
  7. * @param {string} [description]
  8. */
  9. constructor(flags, description) {
  10. this.flags = flags;
  11. this.description = description || '';
  12. this.required = flags.includes('<'); // A value must be supplied when the option is specified.
  13. this.optional = flags.includes('['); // A value is optional when the option is specified.
  14. // variadic test ignores <value,...> et al which might be used to describe custom splitting of single argument
  15. this.variadic = /\w\.\.\.[>\]]$/.test(flags); // The option can take multiple values.
  16. this.mandatory = false; // The option must have a value after parsing, which usually means it must be specified on command line.
  17. const optionFlags = splitOptionFlags(flags);
  18. this.short = optionFlags.shortFlag; // May be a short flag, undefined, or even a long flag (if option has two long flags).
  19. this.long = optionFlags.longFlag;
  20. this.negate = false;
  21. if (this.long) {
  22. this.negate = this.long.startsWith('--no-');
  23. }
  24. this.defaultValue = undefined;
  25. this.defaultValueDescription = undefined;
  26. this.presetArg = undefined;
  27. this.envVar = undefined;
  28. this.parseArg = undefined;
  29. this.hidden = false;
  30. this.argChoices = undefined;
  31. this.conflictsWith = [];
  32. this.implied = undefined;
  33. this.helpGroupHeading = undefined; // soft initialised when option added to command
  34. }
  35. /**
  36. * Set the default value, and optionally supply the description to be displayed in the help.
  37. *
  38. * @param {*} value
  39. * @param {string} [description]
  40. * @return {Option}
  41. */
  42. default(value, description) {
  43. this.defaultValue = value;
  44. this.defaultValueDescription = description;
  45. return this;
  46. }
  47. /**
  48. * Preset to use when option used without option-argument, especially optional but also boolean and negated.
  49. * The custom processing (parseArg) is called.
  50. *
  51. * @example
  52. * new Option('--color').default('GREYSCALE').preset('RGB');
  53. * new Option('--donate [amount]').preset('20').argParser(parseFloat);
  54. *
  55. * @param {*} arg
  56. * @return {Option}
  57. */
  58. preset(arg) {
  59. this.presetArg = arg;
  60. return this;
  61. }
  62. /**
  63. * Add option name(s) that conflict with this option.
  64. * An error will be displayed if conflicting options are found during parsing.
  65. *
  66. * @example
  67. * new Option('--rgb').conflicts('cmyk');
  68. * new Option('--js').conflicts(['ts', 'jsx']);
  69. *
  70. * @param {(string | string[])} names
  71. * @return {Option}
  72. */
  73. conflicts(names) {
  74. this.conflictsWith = this.conflictsWith.concat(names);
  75. return this;
  76. }
  77. /**
  78. * Specify implied option values for when this option is set and the implied options are not.
  79. *
  80. * The custom processing (parseArg) is not called on the implied values.
  81. *
  82. * @example
  83. * program
  84. * .addOption(new Option('--log', 'write logging information to file'))
  85. * .addOption(new Option('--trace', 'log extra details').implies({ log: 'trace.txt' }));
  86. *
  87. * @param {object} impliedOptionValues
  88. * @return {Option}
  89. */
  90. implies(impliedOptionValues) {
  91. let newImplied = impliedOptionValues;
  92. if (typeof impliedOptionValues === 'string') {
  93. // string is not documented, but easy mistake and we can do what user probably intended.
  94. newImplied = { [impliedOptionValues]: true };
  95. }
  96. this.implied = Object.assign(this.implied || {}, newImplied);
  97. return this;
  98. }
  99. /**
  100. * Set environment variable to check for option value.
  101. *
  102. * An environment variable is only used if when processed the current option value is
  103. * undefined, or the source of the current value is 'default' or 'config' or 'env'.
  104. *
  105. * @param {string} name
  106. * @return {Option}
  107. */
  108. env(name) {
  109. this.envVar = name;
  110. return this;
  111. }
  112. /**
  113. * Set the custom handler for processing CLI option arguments into option values.
  114. *
  115. * @param {Function} [fn]
  116. * @return {Option}
  117. */
  118. argParser(fn) {
  119. this.parseArg = fn;
  120. return this;
  121. }
  122. /**
  123. * Whether the option is mandatory and must have a value after parsing.
  124. *
  125. * @param {boolean} [mandatory=true]
  126. * @return {Option}
  127. */
  128. makeOptionMandatory(mandatory = true) {
  129. this.mandatory = !!mandatory;
  130. return this;
  131. }
  132. /**
  133. * Hide option in help.
  134. *
  135. * @param {boolean} [hide=true]
  136. * @return {Option}
  137. */
  138. hideHelp(hide = true) {
  139. this.hidden = !!hide;
  140. return this;
  141. }
  142. /**
  143. * @package
  144. */
  145. _collectValue(value, previous) {
  146. if (previous === this.defaultValue || !Array.isArray(previous)) {
  147. return [value];
  148. }
  149. previous.push(value);
  150. return previous;
  151. }
  152. /**
  153. * Only allow option value to be one of choices.
  154. *
  155. * @param {string[]} values
  156. * @return {Option}
  157. */
  158. choices(values) {
  159. this.argChoices = values.slice();
  160. this.parseArg = (arg, previous) => {
  161. if (!this.argChoices.includes(arg)) {
  162. throw new InvalidArgumentError(
  163. `Allowed choices are ${this.argChoices.join(', ')}.`,
  164. );
  165. }
  166. if (this.variadic) {
  167. return this._collectValue(arg, previous);
  168. }
  169. return arg;
  170. };
  171. return this;
  172. }
  173. /**
  174. * Return option name.
  175. *
  176. * @return {string}
  177. */
  178. name() {
  179. if (this.long) {
  180. return this.long.replace(/^--/, '');
  181. }
  182. return this.short.replace(/^-/, '');
  183. }
  184. /**
  185. * Return option name, in a camelcase format that can be used
  186. * as an object attribute key.
  187. *
  188. * @return {string}
  189. */
  190. attributeName() {
  191. if (this.negate) {
  192. return camelcase(this.name().replace(/^no-/, ''));
  193. }
  194. return camelcase(this.name());
  195. }
  196. /**
  197. * Set the help group heading.
  198. *
  199. * @param {string} heading
  200. * @return {Option}
  201. */
  202. helpGroup(heading) {
  203. this.helpGroupHeading = heading;
  204. return this;
  205. }
  206. /**
  207. * Check if `arg` matches the short or long flag.
  208. *
  209. * @param {string} arg
  210. * @return {boolean}
  211. * @package
  212. */
  213. is(arg) {
  214. return this.short === arg || this.long === arg;
  215. }
  216. /**
  217. * Return whether a boolean option.
  218. *
  219. * Options are one of boolean, negated, required argument, or optional argument.
  220. *
  221. * @return {boolean}
  222. * @package
  223. */
  224. isBoolean() {
  225. return !this.required && !this.optional && !this.negate;
  226. }
  227. }
  228. /**
  229. * This class is to make it easier to work with dual options, without changing the existing
  230. * implementation. We support separate dual options for separate positive and negative options,
  231. * like `--build` and `--no-build`, which share a single option value. This works nicely for some
  232. * use cases, but is tricky for others where we want separate behaviours despite
  233. * the single shared option value.
  234. */
  235. class DualOptions {
  236. /**
  237. * @param {Option[]} options
  238. */
  239. constructor(options) {
  240. this.positiveOptions = new Map();
  241. this.negativeOptions = new Map();
  242. this.dualOptions = new Set();
  243. options.forEach((option) => {
  244. if (option.negate) {
  245. this.negativeOptions.set(option.attributeName(), option);
  246. } else {
  247. this.positiveOptions.set(option.attributeName(), option);
  248. }
  249. });
  250. this.negativeOptions.forEach((value, key) => {
  251. if (this.positiveOptions.has(key)) {
  252. this.dualOptions.add(key);
  253. }
  254. });
  255. }
  256. /**
  257. * Did the value come from the option, and not from possible matching dual option?
  258. *
  259. * @param {*} value
  260. * @param {Option} option
  261. * @returns {boolean}
  262. */
  263. valueFromOption(value, option) {
  264. const optionKey = option.attributeName();
  265. if (!this.dualOptions.has(optionKey)) return true;
  266. // Use the value to deduce if (probably) came from the option.
  267. const preset = this.negativeOptions.get(optionKey).presetArg;
  268. const negativeValue = preset !== undefined ? preset : false;
  269. return option.negate === (negativeValue === value);
  270. }
  271. }
  272. /**
  273. * Convert string from kebab-case to camelCase.
  274. *
  275. * @param {string} str
  276. * @return {string}
  277. * @private
  278. */
  279. function camelcase(str) {
  280. return str.split('-').reduce((str, word) => {
  281. return str + word[0].toUpperCase() + word.slice(1);
  282. });
  283. }
  284. /**
  285. * Split the short and long flag out of something like '-m,--mixed <value>'
  286. *
  287. * @private
  288. */
  289. function splitOptionFlags(flags) {
  290. let shortFlag;
  291. let longFlag;
  292. // short flag, single dash and single character
  293. const shortFlagExp = /^-[^-]$/;
  294. // long flag, double dash and at least one character
  295. const longFlagExp = /^--[^-]/;
  296. const flagParts = flags.split(/[ |,]+/).concat('guard');
  297. // Normal is short and/or long.
  298. if (shortFlagExp.test(flagParts[0])) shortFlag = flagParts.shift();
  299. if (longFlagExp.test(flagParts[0])) longFlag = flagParts.shift();
  300. // Long then short. Rarely used but fine.
  301. if (!shortFlag && shortFlagExp.test(flagParts[0]))
  302. shortFlag = flagParts.shift();
  303. // Allow two long flags, like '--ws, --workspace'
  304. // This is the supported way to have a shortish option flag.
  305. if (!shortFlag && longFlagExp.test(flagParts[0])) {
  306. shortFlag = longFlag;
  307. longFlag = flagParts.shift();
  308. }
  309. // Check for unprocessed flag. Fail noisily rather than silently ignore.
  310. if (flagParts[0].startsWith('-')) {
  311. const unsupportedFlag = flagParts[0];
  312. const baseError = `option creation failed due to '${unsupportedFlag}' in option flags '${flags}'`;
  313. if (/^-[^-][^-]/.test(unsupportedFlag))
  314. throw new Error(
  315. `${baseError}
  316. - a short flag is a single dash and a single character
  317. - either use a single dash and a single character (for a short flag)
  318. - or use a double dash for a long option (and can have two, like '--ws, --workspace')`,
  319. );
  320. if (shortFlagExp.test(unsupportedFlag))
  321. throw new Error(`${baseError}
  322. - too many short flags`);
  323. if (longFlagExp.test(unsupportedFlag))
  324. throw new Error(`${baseError}
  325. - too many long flags`);
  326. throw new Error(`${baseError}
  327. - unrecognised flag format`);
  328. }
  329. if (shortFlag === undefined && longFlag === undefined)
  330. throw new Error(
  331. `option creation failed due to no flags found in '${flags}'.`,
  332. );
  333. return { shortFlag, longFlag };
  334. }
  335. exports.Option = Option;
  336. exports.DualOptions = DualOptions;