Source: lib/shortline.js

  1. /**
  2. * @copyright Copyright 2016 Kevin Locke <kevin@kevinlocke.name>
  3. * @license MIT
  4. */
  5. 'use strict';
  6. const { EOFError, readTo } = require('promised-read');
  7. // Same error text as highline
  8. const EOF_MESSAGE = 'The input stream is exhausted.';
  9. /** Creates a new instance of Shortline which can be used to prompt users
  10. * for input.
  11. *
  12. * This class is intended to be similar to {@link http://highline.rubyforge.org
  13. * Highline}, although it is currently missing nearly all of its functionality.
  14. *
  15. * @constructor
  16. * @param {{
  17. * input: stream.Readable|undefined,
  18. * output: stream.Writable|undefined
  19. * }=} options Options to control the input source and output destination.
  20. */
  21. function Shortline(options) {
  22. const self = this;
  23. self._input = (options && options.input) || process.stdin;
  24. self._output = (options && options.output) || process.stderr;
  25. /** Most recent error emitted by the input stream.
  26. * @type {Error}
  27. */
  28. self.inputError = null;
  29. self._input.on('end', () => {
  30. self.inputError = new EOFError(EOF_MESSAGE);
  31. });
  32. // Note: Can't listen for 'error' since it changes behavior if there are no
  33. // other listeners. Listen for it only when reading from input (since that
  34. // is our error and will be returned to the caller).
  35. }
  36. /** Options for {@link Shortline#ask}.
  37. *
  38. * @ template ReturnType
  39. * @typedef {{
  40. * convert: ((function(string): ReturnType)|undefined),
  41. * default: string|undefined,
  42. * responses: {
  43. * notValid: string|undefined
  44. * }|undefined,
  45. * trim: boolean|undefined,
  46. * validate: RegExp|undefined
  47. * }} ShortlineAskOptions
  48. * @property {(function(string): ReturnType)=} convert Type conversion used to
  49. * create the return value from the trimmed and validated user input.
  50. * @property {string=} default Default value used in place of empty user input.
  51. * @property {{notValid: string|undefined}} responses Responses to various user
  52. * input. <code>notValid</code> is printed if the input does not validate.
  53. * @property {boolean=} trim Right-trim user input?
  54. * @property {RegExp=} Prompt repeatedly until the trimmed user input matches a
  55. * given RegExp.
  56. */
  57. // var ShortlineAskOptions;
  58. /** Asks the user a "yes or no" question.
  59. *
  60. * @param {string} question Question to ask the user.
  61. * @param {ShortlineAskOptions=} options Options.
  62. * @return {!Promise<ReturnType>} Promise with result of
  63. * <code>options.convert</code> applied to the user-entered text, or Error.
  64. * @private
  65. */
  66. Shortline.prototype.agree = function agree(question, options) {
  67. options = {
  68. convert: function agreeToBoolean(answer) {
  69. return answer.charAt(0).toLowerCase() === 'y';
  70. },
  71. validate: /^y(?:es)?|no?$/i,
  72. ...options,
  73. };
  74. options.responses = {
  75. notValid: 'Please enter "yes" or "no".',
  76. ...options.responses,
  77. };
  78. return this.ask(question, options);
  79. };
  80. /** Asks the user to provide input.
  81. *
  82. * @ template ReturnType
  83. * @param {string} question Question to ask the user.
  84. * @param {ShortlineAskOptions=} options Options.
  85. * @return {!Promise<ReturnType>} Promise with result of
  86. * <code>options.convert</code> applied to the user-entered text, or Error.
  87. * @private
  88. */
  89. Shortline.prototype.ask = function ask(question, options) {
  90. const self = this;
  91. options = options || {};
  92. let fullQuestion = question;
  93. if (options.default) {
  94. fullQuestion = question.replace(
  95. /\s*$/,
  96. (padding) => ` |${options.default}|${padding || ' '}`,
  97. );
  98. }
  99. return self.prompt(fullQuestion).then((answer) => {
  100. if (options.default) {
  101. answer = answer || options.default;
  102. }
  103. if (options.trim) {
  104. answer = answer.trimEnd();
  105. }
  106. if (options.validate && !options.validate.test(answer)) {
  107. let response = options.responses && options.responses.notValid;
  108. if (!response) {
  109. response = `Your answer isn't valid (must match ${
  110. options.validate.source}).`;
  111. }
  112. self._output.write(`${response}\n`);
  113. return self.ask(question, options);
  114. }
  115. if (options.convert) {
  116. answer = options.convert(answer);
  117. }
  118. return answer;
  119. });
  120. };
  121. /** Prompts the user for input without validation, retry, type conversion, or
  122. * trimming.
  123. *
  124. * @param {string} text Text with which to prompt the user.
  125. * @return {!Promise<string>} Promise with user-entered text up to (but not
  126. * including) the first newline or Error.
  127. * @private
  128. */
  129. Shortline.prototype.prompt = function prompt(text) {
  130. const self = this;
  131. const input = self._input;
  132. const output = self._output;
  133. output.write(text);
  134. if (self.inputError) {
  135. return Promise.reject(self.inputError);
  136. }
  137. function onError(err) {
  138. self.inputError = err;
  139. }
  140. input.once('error', onError);
  141. return readTo(input, '\n').then(
  142. (result) => {
  143. input.removeListener('error', onError);
  144. // Trim \n, which is considered a non-input signaling character
  145. // Convert to a string, since stream may not have an encoding set
  146. return String(result.slice(0, -1));
  147. },
  148. (err) => {
  149. input.removeListener('error', onError);
  150. if (err.name === 'EOFError') {
  151. err.message = EOF_MESSAGE;
  152. }
  153. throw err;
  154. },
  155. );
  156. };
  157. module.exports = Shortline;