'use strict' const format = require('util').format class Context { static get (opts) { return new Context(opts) } constructor (opts) { opts = opts || {} // dependencies this._utils = opts.utils this._pathLib = opts.pathLib this._fsLib = opts.fsLib // config this.types = {} // args to parse per type this.args = [] this.slurped = [] // values by type, keyed by type.id this.values = new Map() this.sources = new Map() // results of parsing and validation this.code = 0 this.output = '' this.argv = {} this.knownArgv = {} this.details = { args: [], types: [] } this.errors = [] this.messages = [] // other this.commandHandlerRun = false this.helpRequested = false this.versionRequested = false } get utils () { if (!this._utils) this._utils = require('./lib/utils').get() return this._utils } get pathLib () { if (!this._pathLib) this._pathLib = require('path') return this._pathLib } get fsLib () { if (!this._fsLib) this._fsLib = require('fs') return this._fsLib } slurpArgs (args) { if (typeof args === 'string') args = this.utils.stringToArgs(args) if (!args) args = process.argv.slice(2) if (!Array.isArray(args)) args = [].concat(args) // TODO read from stdin with no args? based on config? const parseable = [] const extra = [] let isExtra = false for (let i = 0, len = args.length, arg; i < len; i++) { arg = String(args[i]) if (arg === '--') { isExtra = true // continue } (isExtra ? extra : parseable).push(arg) } this.args = parseable this.details.args = parseable.concat(extra) // let prev = [{}] // this.argv = this.args.reduce((argv, arg) => { // let kvArray = this.parseSingleArg(arg) // kvArray.forEach(kv => { // if (kv.key) argv[kv.key] = kv.value // else argv._.push(kv.value) // }) // if (!kvArray[kvArray.length - 1].key && prev[prev.length - 1].key) { // argv[prev[prev.length - 1].key] = kvArray[kvArray.length - 1].value // argv._ = argv._.slice(0, -1) // } // prev = kvArray // return argv // }, { _: [] }) // console.log('context.js > argv:', this.argv) this.slurped = this.args.map((arg, index) => { return { raw: arg, index, parsed: this.parseSingleArg(arg) } }) // console.log('context.js > slurped:', JSON.stringify(this.slurped, null, 2)) return this } parseSingleArg (arg) { // short-circuit if no flag const numBeginningDashes = (arg.match(/^-+/) || [''])[0].length if (numBeginningDashes === 0) { return [{ key: '', value: arg }] } // otherwise check for =value somewhere in arg const kvDelimIndex = arg.indexOf('=') const flags = kvDelimIndex > 1 ? arg.substring(numBeginningDashes, kvDelimIndex) : arg.slice(numBeginningDashes) const value = kvDelimIndex > 1 ? arg.substring(kvDelimIndex + 1) : undefined // allow an arg of just dashes e.g. '-' if (!flags && !value) { return [{ key: '', value: arg }] } // can only be one flag with more than 1 dash if (numBeginningDashes > 1) { return [{ key: flags, value: value || true }] } // may be multiple single-length flags, with value belonging to the last one const kvArray = flags.split('').map(flag => { return { key: flag, value: true } }) if (value) kvArray[kvArray.length - 1].value = value return kvArray } pushLevel (level, types) { this.types[level] = types return this } unexpectedError (err) { this.errors.push(err) this.output = String((err && err.stack) || err) this.code++ } cliMessage (msg) { // do NOT modify this.code here - the messages will be disregarded if help is requested const argsLen = arguments.length const args = new Array(argsLen - 1) for (let i = 0; i < argsLen; ++i) { args[i] = arguments[i] // if any args are an array, join into string // this is a hack to get Node 12's util.format working like Node 10's // e.g. require('util').format("Value \"%s\" is invalid.", ["web", "docs"]) // Node 10: Value "web,docs" is invalid. // Node 12: Value "[ 'web', 'docs' ]" is invalid. if (Array.isArray(args[i])) { args[i] = args[i].join(',') } } this.messages.push(format.apply(null, args)) } markTypeInvalid (id) { const mappedLevels = Object.keys(this.types) for (let i = mappedLevels.length - 1, currentLevel, found; i >= 0; i--) { currentLevel = mappedLevels[i] found = (this.types[currentLevel] || []).find(type => type.id === id) if (found) { found.invalid = true return } } } explicitCommandMatch (level) { if (!this.argv._ || !this.argv._.length) return false const candidate = this.argv._[0] return (this.types[level] || []).some(type => type.datatype === 'command' && type.aliases.some(alias => alias === candidate)) } matchCommand (level, aliases, isDefault) { if (!this.argv._ || this.versionRequested) return false // TODO what to do without an unknownType? // first determine if argv._ starts with ANY known command alias const matchFound = this.explicitCommandMatch(level) const candidate = this.argv._[0] return { explicit: matchFound && aliases.some(alias => alias === candidate), implicit: !matchFound && isDefault && !this.helpRequested, candidate: candidate } } deferHelp (opts) { this.helpRequested = opts || {} return this } addDeferredHelp (helpBuffer) { const groups = {} const mappedLevels = Object.keys(this.types) for (let i = mappedLevels.length - 1, currentLevel; i >= 0; i--) { currentLevel = mappedLevels[i] ;(this.types[currentLevel] || []).forEach(type => { if (currentLevel === helpBuffer._usageName || type.datatype !== 'command') { if (this.helpRequested) type.invalid = false groups[type.helpGroup] = (groups[type.helpGroup] || []).concat(type) } }) } helpBuffer.groups = groups if (!this.helpRequested) { helpBuffer.messages = this.messages this.code += this.messages.length } // add/set output to helpBuffer.toString() this.output = helpBuffer.toString(this.helpRequested) return this } addHelp (helpBuffer, opts) { return this.deferHelp(opts).addDeferredHelp(helpBuffer) } deferVersion (opts) { this.versionRequested = opts || {} return this } addDeferredVersion () { if (!(this.versionRequested && this.versionRequested.version)) { let dir = this.pathLib.dirname(require.main.filename) const root = this.pathLib.parse(dir).root let version let attempts = 0 // protect against infinite tight loop if libs misbehave while (dir !== root && attempts < 999) { attempts++ try { version = JSON.parse(this.fsLib.readFileSync(this.pathLib.join(dir, 'package.json'), 'utf8')).version if (version) break } catch (_) { dir = this.pathLib.dirname(dir) } } if (!this.versionRequested) this.versionRequested = {} this.versionRequested.version = version || 'Version unknown' } if (typeof this.versionRequested.version === 'function') this.output = this.versionRequested.version() else this.output = this.versionRequested.version return this } // weird method names make for easier code searching assignValue (id, value) { this.values.set(id, value) } lookupValue (id) { return this.values.get(id) } resetSource (id, source) { this.sources.set(id, { source: source, position: [], raw: [] }) } employSource (id, source, position, raw) { let obj = this.lookupSource(id) if (!obj) { obj = { source: undefined, position: [], raw: [] } this.sources.set(id, obj) } if (typeof source === 'string') obj.source = source if (typeof position === 'number') obj.position.push(position) if (typeof raw === 'string') obj.raw.push(raw) } lookupSource (id) { return this.sources.get(id) } lookupSourceValue (id) { const obj = this.lookupSource(id) return obj && obj.source } populateArgv (typeResults) { let detailIndex typeResults.forEach(tr => { // find and reset detailed object; otherwise add it detailIndex = this.details.types.findIndex(t => t.parent === tr.parent && t.datatype === tr.datatype && this.utils.sameArrays(tr.aliases, t.aliases)) if (detailIndex !== -1) this.details.types[detailIndex] = tr else this.details.types.push(tr) // if not command, set value for each alias in argv if (tr.datatype === 'command') return undefined // do not add command aliases to argv tr.aliases.forEach(alias => { this.argv[alias] = tr.value this.knownArgv[alias] = tr.value }) }) } getUnknownArguments () { if (!Array.isArray(this.argv._)) return [] const endOptions = this.argv._.indexOf('--') return this.argv._.slice(0, endOptions === -1 ? this.argv._.length : endOptions) } getUnknownSlurpedOptions () { return Object.keys(this.argv).filter(key => !(key in this.knownArgv)).map(key => { return this.slurped.find(arg => arg.parsed.some(p => p.key === key)) }) } toResult () { return { code: this.code, output: this.output, errors: this.errors, argv: this.argv, details: this.details } } } module.exports = Context