serverless-spy
Version:
CDK-based library for writing elegant integration tests on AWS serverless architecture and an additional web console to monitor events in real time.
374 lines (310 loc) • 11.2 kB
JavaScript
const fs = require('fs');
const path = require('path');
const read = fs.readFileSync;
const exists = fs.existsSync;
const join = path.join;
const debug = require('./debug')('tabtab:complete');
const assign = require('object-assign');
const uniq = require('lodash.uniq');
const difference = require('lodash.difference');
const { EventEmitter } = require('events');
const CacheMixin = require('./cache');
// Public: Complete class. This is the main API to interract with the
// completion system and extends EventEmitter.
//
// Examples
//
// var complete = new Complete({
// name: 'binary-name'
// });
//
// complete.on('list', function(data, done) {
// return done(null, ['completion', 'result', 'here']);
// });
class Complete extends EventEmitter {
// Public: Options defaults.
//
// Returns the binary name being completed. Uses process.title if not the
// default "node" value, or attempt to determine the package name from
// package.json file.
get defaults() {
return {
name: process.title !== 'node' ? process.title : ''
};
}
// Public: Complete constructor, accepts options hash with
//
// options - Accepts options hash (default: {})
//
// Examples
//
// new Complete({
// // the String package name being completed, defaults to process.title
// // (if not node default) or will attempt to determine parent's
// // package.json location and extract the name from it.
// name: 'foobar'
//
// // Enable / Disable cache (defaults: true)
// cache: true,
//
// // Cache Time To Live duration in ms (default: 5min)
// ttl: 1000 * 60 * 5
// });
constructor(options) {
super();
this.options = options || this.defaults;
this.options.name = this.options.name || this.resolve('name');
this.options.cache = typeof this.options.cache !== 'undefined' ? this.options.cache : true;
this.options.ttl = this.options.ttl || (1000 * 60 * 5);
this._results = [];
this._completions = [];
}
start() {
this.handle();
}
// Public: Takes care of all the completion plumbing mechanism.
//
// It checks the environment to determine if we act in plumbing mode or not,
// to parse COMP args and emit the appropriated events to gather completion
// results.
//
// options - options hash to pass to self#parseEnv
handle(options) {
options = assign({}, options, this.options);
var name = options.name;
if (!name) throw new Error('Cannot determine package name');
var env = this.env = this.parseEnv(options);
if (env.args[0] !== 'completion') return;
var line = env.line.replace(name, '').trim();
var first = line.split(' ')[0];
if (first) first = first.trim();
var event = (first || env.prev || name).trim();
var cache = this.cache(env.line);
if (this.options.cache && cache) {
return process.nextTick(() => {
// this.addCompletions(cache.value);
this.send(event + ':cache', env);
debug('cache', cache.value);
this.output(cache.value);
});
}
if (!env.complete) {
return debug('Completion command but without COMP args');
}
process.nextTick(() => {
this.completePackage(env);
var prev = env.prev.trim();
name = name.trim();
// Keeps emitting event only if previous one is not being listened to.
// Emits in series: first, prev and name.
this.send(event, env, this.recv.bind(this));
if (prev !== event) this.send(prev, env, this.recv.bind(this));
if (name !== event && name !== prev) this.send(name, env, this.recv.bind(this));
if (this.options.cache) {
this.cache(env.line, this._results);
this.writeToStore(this.cacheStore);
}
});
}
output(results) {
var shell = (process.env.SHELL || '').split('/').slice(-1)[0];
if (shell === 'bash') {
results = results.filter((res) => {
return res.indexOf(this.env.last) === 0;
});
}
// create a duplicate-free version of results
results = uniq(results);
// only include results different from past completion results
results = difference(results, this._completions);
this._completions = this._completions.concat(results);
debug('results:', results, this.env);
console.log(results.join('\n'));
}
send(evt, env, done) {
var res = this.emit(evt, env, done);
debug('Emit evt: %s (listener: %s)', evt, res);
return res;
}
completePackage(env, stop) {
var config = this.resolve('tabtab');
if (!config) return;
var pkgname = config[this.options.name];
var last = (env.last || env.prev).trim();
var prop = last || this.options.name;
if (!prop) return;
if (stop) {
let first = env.line.split(' ')[0];
let results = config[first];
if (!results) return;
return this.recv(null, results, env);
}
// Keeps looking up for completion only if previous one have not returned
// any results.
var command = config[prop];
var completions = this.recv(null, command, env);
if (!completions) {
if (last && !stop) {
let reg = new RegExp('\\s*' + last + '\\s*$');
let line = env.line.replace(reg, '');
this.completePackage(this.parseEnv({
env: {
COMP_LINE: line,
COMP_WORD: env.words,
COMP_POINT: env.point
}
}), true);
}
}
return true
}
// Public: Completions handlers
//
// will call back this function with an Array of completion items.
//
// err - Error object
// completions - The Array of String completion results to write to stdout
// env - Env object as parsed by parseEnv
recv(err, completions, env) {
if (!completions) return;
env = env || this.env;
debug('Received %s', completions);
if (err) return this.emit('error', err);
this.addCompletions(completions);
this.output(this._results);
return completions;
}
addCompletions(completions) {
completions = Array.isArray(completions) ? completions : [completions];
var shell = (process.env.SHELL || '').split('/').slice(-1)[0];
completions = completions.map(this.completionItem).map((item) => {
return shell === 'zsh' ? `${item.name.replace(/:/g, '\\:')}:${item.description}` :
shell === 'fish' ? `${item.name}\t${item.description}` :
item.name;
});
this._results = this._results.concat(completions);
}
completionItem(str) {
debug('completion item', str, typeof str);
if (typeof str !== 'string') return str;
var shell = (process.env.SHELL || '').split('/').slice(-1)[0];
var parts = str.split(/(\\)?:/);
var name = parts[0];
var desc = parts.slice(-1)[0];
if (desc === name) {
desc = '';
}
if (shell === 'zsh' && /\\/.test(str)) {
name = name + '\\';
}
return {
name: name,
description: desc
};
}
// Public: Main utility to extract information from command line arguments and
// Environment variables, namely COMP args in "plumbing" mode.
//
// options - The options hash as parsed by minimist, plus an env property
// representing user environment (default: { env: process.env })
// :_ - The arguments Array parsed by minimist (positional arguments)
// :env - The environment Object that holds COMP args (default: process.env)
//
// Examples
//
// var env = complete.parseEnv();
// // env:
// // args the positional arguments used
// // complete A Boolean indicating whether we act in "plumbing mode" or not
// // words The Number of words in the completed line
// // point A Number indicating cursor position
// // line The String input line
// // partial The String part of line preceding cursor position
// // last The last String word of the line
// // lastPartial The last word String of partial
// // prev The String word preceding last
//
// Returns the data env object.
parseEnv(options) {
options = options || {}
options = assign({}, options, this.options);
var args = options._ || process.argv.slice(2);
var env = options.env || process.env;
var cword = Number(env.COMP_CWORD);
var point = Number(env.COMP_POINT);
var line = env.COMP_LINE || '';
if (isNaN(cword)) cword = 0;
if (isNaN(point)) point = 0;
var partial = line.slice(0, point);
var parts = line.split(' ');
var prev = parts.slice(0, -1).slice(-1)[0];
var last = parts.slice(-1).join('');
var lastPartial = partial.split(' ').slice(-1).join('');
var complete = args[0] === 'completion';
if (!env.COMP_CWORD || typeof env.COMP_POINT === 'undefined' || typeof env.COMP_LINE === 'undefined') {
complete = false;
}
return {
args: args,
complete: complete,
words: cword,
point: point,
line: line,
partial: partial,
last: last,
lastPartial: lastPartial,
prev: prev || ''
};
}
// Public: Script templating helper
//
// Outputs npm's completion script with pkgname and completer placeholder
// replaced.
//
// name - The String package/binary name
// complete - The String completer name, usualy the same as name above. Can
// differ to delegate the completion behavior to another command.
//
// Returns the script content with placeholders replaced
script(name, completer, shell) {
return read(join(__dirname, `../scripts/${shell || 'completion'}.sh`), 'utf8')
.replace(/\{pkgname\}/g, name)
.replace(/{completer}/g, completer);
}
// Public: Recursively walk up the `module.parent` chain to find original file.
findParent(module, last) {
if (!module.parent) return module;
return this.findParent(module.parent);
}
// Public: Recursively walk up the directories, untill it finds the `file`
// provided, or reach the user $HOME dir.
findUp(file, dir) {
dir = path.resolve(dir || './');
// stop at user $HOME dir
if (dir === process.env.HOME) return;
if (exists(join(dir, file))) return join(dir, file);
return this.findUp(file, path.dirname(dir));
}
// Public: package name resolver.
//
// When options.name is not defined, this gets called to attempt to determine
// completer name.
//
// It'll attempt to follow the module chain and find the package.json file to
// determine the command name being completed.
resolve(prop) {
// `module` is special node builtin
var parent = this.findParent(module);
if (!parent) return;
var dirname = path.dirname(parent.filename);
// was invoked by cli tabtab, fallback to local package.json
if (parent.filename === path.join(__dirname, '../bin/tabtab')) {
dirname = path.resolve();
}
var jsonfile = this.findUp('package.json', dirname);
if (!jsonfile) return;
return require(jsonfile)[prop];
}
}
assign(Complete.prototype, CacheMixin);
module.exports = Complete;