acuminous/yadda

View on GitHub
lib/parsers/FeatureParser.js

Summary

Maintainability
B
4 hrs
Test Coverage
A
98%
'use strict';

var $ = require('../Array');
var fn = require('../fn');
var StringUtils = require('../StringUtils');
var Localisation = require('../localisation');

var FeatureParser = function (options) {
  /* eslint-disable no-redeclare */
  var defaults = { language: Localisation.default, leftPlaceholderChar: '[', rightPlaceholderChar: ']' };
  var options = options && options.is_language ? { language: options } : options || defaults;
  var language = options.language || defaults.language;
  var left_placeholder_char = options.leftPlaceholderChar || defaults.leftPlaceholderChar;
  var right_placeholder_char = options.rightPlaceholderChar || defaults.rightPlaceholderChar;
  /* eslint-enable no-redeclare */

  var FEATURE_REGEX = new RegExp('^\\s*' + language.localise('feature') + ':\\s*(.*)', 'i');
  var SCENARIO_REGEX = new RegExp('^\\s*' + language.localise('scenario') + ':\\s*(.*)', 'i');
  var BACKGROUND_REGEX = new RegExp('^\\s*' + language.localise('background') + ':\\s*(.*)', 'i');
  var EXAMPLES_REGEX = new RegExp('^\\s*' + language.localise('examples') + ':', 'i');
  var TEXT_REGEX = new RegExp('^(.*)$', 'i');
  var SINGLE_LINE_COMMENT_REGEX = new RegExp('^\\s*#');
  var MULTI_LINE_COMMENT_REGEX = new RegExp('^\\s*#{3,}');
  var BLANK_REGEX = new RegExp('^(\\s*)$');
  var DASH_REGEX = new RegExp('(^\\s*[\\|\u2506]?-{3,})');
  var SIMPLE_ANNOTATION_REGEX = new RegExp('^\\s*@([^=]*)$');
  var NVP_ANNOTATION_REGEX = new RegExp('^\\s*@([^=]*)=(.*)$');

  var specification;
  var comment;

  this.parse = function (text, next) {
    reset();
    split(text).each(parse_line);
    return (next && next(specification.export())) || specification.export();
  };

  function reset() {
    specification = new Specification();
    comment = false;
  }

  function split(text) {
    return $(text.split(/\r\n|\n/));
  }

  function parse_line(line, index) {
    var match;
    var line_number = index + 1;
    try {
      // eslint-disable-next-line no-return-assign
      if ((match = MULTI_LINE_COMMENT_REGEX.test(line))) return (comment = !comment);
      if (comment) return;
      if ((match = SINGLE_LINE_COMMENT_REGEX.test(line))) return;
      if ((match = SIMPLE_ANNOTATION_REGEX.exec(line))) return specification.handle('Annotation', { key: StringUtils.trim(match[1]), value: true }, line_number);
      if ((match = NVP_ANNOTATION_REGEX.exec(line))) return specification.handle('Annotation', { key: StringUtils.trim(match[1]), value: StringUtils.trim(match[2]) }, line_number);
      if ((match = FEATURE_REGEX.exec(line))) return specification.handle('Feature', match[1], line_number);
      if ((match = SCENARIO_REGEX.exec(line))) return specification.handle('Scenario', match[1], line_number);
      if ((match = BACKGROUND_REGEX.exec(line))) return specification.handle('Background', match[1], line_number);
      if ((match = EXAMPLES_REGEX.exec(line))) return specification.handle('Examples', line_number);
      if ((match = BLANK_REGEX.exec(line))) return specification.handle('Blank', match[0], line_number);
      if ((match = DASH_REGEX.exec(line))) return specification.handle('Dash', match[1], line_number);
      if ((match = TEXT_REGEX.exec(line))) return specification.handle('Text', match[1], line_number);
    } catch (e) {
      e.message = 'Error parsing line ' + line_number + ', "' + line + '".\nOriginal error was: ' + e.message;
      throw e;
    }
  }

  var Handlers = function (handlers) {
    // eslint-disable-next-line no-redeclare
    var handlers = handlers || {};

    this.register = function (event, handler) {
      handlers[event] = handler;
    };

    this.unregister = function () {
      $(Array.prototype.slice.call(arguments)).each(function (event) {
        delete handlers[event];
      });
    };

    this.find = function (event) {
      if (!handlers[event.toLowerCase()]) throw new Error(event + ' is unexpected at this time');
      return { handle: handlers[event.toLowerCase()] };
    };
  };

  var Specification = function () {
    var current_element = this;
    var feature;
    var annotations = new Annotations();
    var handlers = new Handlers({
      text: fn.noop,
      blank: fn.noop,
      annotation: stash_annotation,
      feature: start_feature,
      scenario: start_scenario,
      background: start_background,
    });

    function stash_annotation(event, annotation) {
      handlers.unregister('background');
      annotations.stash(annotation.key, annotation.value);
    }

    function start_feature(event, title) {
      // eslint-disable-next-line no-return-assign
      return (feature = new Feature(title, annotations, new Annotations()));
    }

    function start_scenario(event, title, line_number) {
      feature = new Feature(title, new Annotations(), annotations);
      return feature.on(event, title, line_number);
    }

    var start_background = start_scenario;

    this.handle = function (event, data, line_number) {
      current_element = current_element.on(event, data, line_number);
    };

    this.on = function (event, data, line_number) {
      return handlers.find(event).handle(event, data, line_number) || this;
    };

    this.export = function () {
      if (!feature) throw new Error('A feature must contain one or more scenarios');
      return feature.export();
    };
  };

  var Annotations = function () {
    var annotations = {};

    this.stash = function (key, value) {
      if (/\s/.test(key)) throw new Error('Invalid annotation: ' + key);
      annotations[key.toLowerCase()] = value;
    };

    this.export = function () {
      return annotations;
    };
  };

  var Feature = function (title, annotations, stashed_annotations) {
    var description = [];
    var scenarios = [];
    var background = new NullBackground();
    var handlers = new Handlers({
      text: capture_description,
      blank: fn.noop,
      annotation: stash_annotation,
      scenario: start_scenario,
      background: start_background,
    });
    var _this = this;

    function start_background(event, title) {
      background = new Background(title, _this);
      stashed_annotations = new Annotations();
      return background;
    }

    function stash_annotation(event, annotation) {
      handlers.unregister('background', 'text');
      stashed_annotations.stash(annotation.key, annotation.value);
    }

    function capture_description(event, text) {
      description.push(StringUtils.trim(text));
    }

    function start_scenario(event, title) {
      var scenario = new Scenario(title, background, stashed_annotations, _this);
      scenarios.push(scenario);
      stashed_annotations = new Annotations();
      return scenario;
    }

    function validate() {
      if (scenarios.length === 0) throw new Error('Feature requires one or more scenarios');
    }

    this.on = function (event, data, line_number) {
      return handlers.find(event).handle(event, data, line_number) || this;
    };

    this.export = function () {
      validate();
      return {
        title: title,
        annotations: annotations.export(),
        description: description,
        scenarios: $(scenarios)
          .collect(function (scenario) {
            return scenario.export();
          })
          .flatten()
          .naked(),
      };
    };
  };

  var Background = function (title, feature) {
    var steps = [];
    var blanks = [];
    var indentation = 0;
    var handlers = new Handlers({
      text: capture_step,
      blank: fn.noop,
      annotation: stash_annotation,
      scenario: start_scenario,
    });

    function capture_step(event, text, line_number) {
      handlers.register('dash', enable_multiline_step);
      steps.push(StringUtils.trim(text));
    }

    function enable_multiline_step(event, text, line_number) {
      handlers.unregister('dash', 'annotation', 'scenario');
      handlers.register('text', start_multiline_step);
      handlers.register('blank', stash_blanks);
      indentation = StringUtils.indentation(text);
    }

    function start_multiline_step(event, text, line_number) {
      handlers.register('dash', disable_multiline_step);
      handlers.register('text', continue_multiline_step);
      handlers.register('blank', stash_blanks);
      handlers.register('annotation', stash_annotation);
      handlers.register('scenario', start_scenario);
      append_to_step(text, '\n');
    }

    function continue_multiline_step(event, text, line_number) {
      unstash_blanks();
      append_to_step(text, '\n');
    }

    function stash_blanks(event, text, line_number) {
      blanks.push(text);
    }

    function unstash_blanks() {
      if (!blanks.length) return;
      append_to_step(blanks.join('\n'), '\n');
      blanks = [];
    }

    function disable_multiline_step(event, text, line_number) {
      handlers.unregister('dash');
      handlers.register('text', capture_step);
      handlers.register('blank', fn.noop);
      unstash_blanks();
    }

    function append_to_step(text, prefix) {
      if (StringUtils.isNotBlank(text) && StringUtils.indentation(text) < indentation) throw new Error('Indentation error');
      steps[steps.length - 1] = steps[steps.length - 1] + prefix + StringUtils.rtrim(text.substr(indentation));
    }

    function stash_annotation(event, annotation, line_number) {
      validate();
      return feature.on(event, annotation, line_number);
    }

    function start_scenario(event, data, line_number) {
      validate();
      return feature.on(event, data, line_number);
    }

    function validate() {
      if (steps.length === 0) throw new Error('Background requires one or more steps');
    }

    this.on = function (event, data, line_number) {
      return handlers.find(event).handle(event, data, line_number) || this;
    };

    this.export = function () {
      validate();
      return {
        steps: steps,
      };
    };
  };

  var NullBackground = function () {
    var handlers = new Handlers();

    this.on = function (event, data, line_number) {
      return handlers.find(event).handle(event, data, line_number) || this;
    };

    this.export = function () {
      return {
        steps: [],
      };
    };
  };

  var Scenario = function (title, background, annotations, feature) {
    var description = [];
    var steps = [];
    var blanks = [];
    var examples;
    var indentation = 0;
    var handlers = new Handlers({
      text: capture_step,
      blank: fn.noop,
      annotation: start_scenario,
      scenario: start_scenario,
      examples: start_examples,
    });
    var _this = this;

    function capture_step(event, text, line_number) {
      handlers.register('dash', enable_multiline_step);
      steps.push(StringUtils.trim(text));
    }

    function enable_multiline_step(event, text, line_number) {
      handlers.unregister('dash', 'annotation', 'scenario', 'examples');
      handlers.register('text', start_multiline_step);
      handlers.register('blank', stash_blanks);
      indentation = StringUtils.indentation(text);
    }

    function start_multiline_step(event, text, line_number) {
      handlers.register('dash', disable_multiline_step);
      handlers.register('text', continue_multiline_step);
      handlers.register('blank', stash_blanks);
      handlers.register('annotation', start_scenario);
      handlers.register('scenario', start_scenario);
      handlers.register('examples', start_examples);
      append_to_step(text, '\n');
    }

    function continue_multiline_step(event, text, line_number) {
      unstash_blanks();
      append_to_step(text, '\n');
    }

    function stash_blanks(event, text, line_number) {
      blanks.push(text);
    }

    function unstash_blanks() {
      if (!blanks.length) return;
      append_to_step(blanks.join('\n'), '\n');
      blanks = [];
    }

    function disable_multiline_step(event, text, line_number) {
      handlers.unregister('dash');
      handlers.register('text', capture_step);
      handlers.register('blank', fn.noop);
      unstash_blanks();
    }

    function append_to_step(text, prefix) {
      if (StringUtils.isNotBlank(text) && StringUtils.indentation(text) < indentation) throw new Error('Indentation error');
      steps[steps.length - 1] = steps[steps.length - 1] + prefix + StringUtils.rtrim(text.substr(indentation));
    }

    function start_scenario(event, data, line_number) {
      validate();
      return feature.on(event, data, line_number);
    }

    function start_examples(event, data, line_number) {
      validate();
      examples = new Examples(_this);
      return examples;
    }

    function validate() {
      if (steps.length === 0) throw new Error('Scenario requires one or more steps');
    }

    this.on = function (event, data, line_number) {
      return handlers.find(event).handle(event, data, line_number) || this;
    };

    this.export = function () {
      validate();
      var result = {
        title: title,
        annotations: annotations.export(),
        description: description,
        steps: background.export().steps.concat(steps),
      };
      return examples ? examples.expand(result) : result;
    };
  };

  var Examples = function (scenario) {
    var headings = [];
    var examples = $();
    var annotations = new Annotations();
    var handlers = new Handlers({
      blank: fn.noop,
      dash: start_example_table,
      text: capture_headings,
    });

    function start_example_table(evnet, data, line_number) {
      handlers.unregister('blank', 'dash');
    }

    function capture_headings(event, data, line_number) {
      handlers.register('annotation', stash_annotation);
      handlers.register('text', capture_singleline_fields);
      handlers.register('dash', enable_multiline_examples);
      var pos = 1;
      headings = split(data)
        .collect(function (column) {
          var attributes = { text: StringUtils.trim(column), left: pos, indentation: StringUtils.indentation(column) };
          pos += column.length + 1;
          return attributes;
        })
        .naked();
    }

    function stash_annotation(event, annotation, line_number) {
      handlers.unregister('blank', 'dash');
      annotations.stash(annotation.key, annotation.value);
    }

    function capture_singleline_fields(event, data, line_number) {
      handlers.register('dash', end_example_table);
      handlers.register('blank', end_example_table);
      examples.push({ annotations: annotations, fields: parse_fields(data, {}) });
      add_meta_fields(line_number);
      annotations = new Annotations();
    }

    function enable_multiline_examples(event, data, line_number) {
      handlers.register('text', start_capturing_multiline_fields);
      handlers.register('dash', stop_capturing_multiline_fields);
    }

    function start_capturing_multiline_fields(event, data, line_number) {
      handlers.register('text', continue_capturing_multiline_fields);
      handlers.register('dash', stop_capturing_multiline_fields);
      handlers.register('blank', end_example_table);
      examples.push({ annotations: annotations, fields: parse_fields(data, {}) });
      add_meta_fields(line_number);
    }

    function continue_capturing_multiline_fields(event, data, line_number) {
      parse_fields(data, examples.last().fields);
    }

    function stop_capturing_multiline_fields(event, data, line_number) {
      handlers.register('text', start_capturing_multiline_fields);
      annotations = new Annotations();
    }

    function end_example_table(event, data, line_number) {
      handlers.unregister('text', 'dash');
      handlers.register('blank', fn.noop);
      handlers.register('annotation', start_scenario);
      handlers.register('scenario', start_scenario);
    }

    function add_meta_fields(line_number) {
      var fields = examples.last().fields;
      $(headings).each(function (heading) {
        fields[heading.text + '.index'] = [examples.length];
        fields[heading.text + '.start.line'] = [line_number];
        fields[heading.text + '.start.column'] = [heading.left + heading.indentation];
      });
    }

    function parse_fields(row, fields) {
      split(row, headings.length).each(function (field, index) {
        var column = headings[index].text;
        var indentation = headings[index].indentation;
        var text = StringUtils.rtrim(field.substr(indentation));
        if (StringUtils.isNotBlank(field) && StringUtils.indentation(field) < indentation) throw new Error('Indentation error');
        fields[column] = (fields[column] || []).concat(text);
      });
      return fields;
    }

    function split(row, number_of_fields) {
      var separator = row.indexOf('\u2506') >= 0 ? '\u2506' : '|';
      var fields = $(row.split(separator));
      if (number_of_fields !== undefined && number_of_fields !== fields.length) {
        throw new Error('Incorrect number of fields in example table. Expected ' + number_of_fields + ' but found ' + fields.length);
      }
      return fields;
    }

    function start_scenario(event, data, line_number) {
      validate();
      return scenario.on(event, data, line_number);
    }

    function validate() {
      if (headings.length === 0) throw new Error('Examples table requires one or more headings');
      if (examples.length === 0) throw new Error('Examples table requires one or more rows');
    }

    this.on = function (event, data, line_number) {
      return handlers.find(event).handle(event, data, line_number) || this;
    };

    this.expand = function (scenario) {
      validate();
      return examples
        .collect(function (example) {
          return {
            title: substitute(example.fields, scenario.title),
            annotations: shallow_merge(example.annotations.export(), scenario.annotations),
            description: substitute_all(example, scenario.description),
            steps: substitute_all(example.fields, scenario.steps),
          };
        })
        .naked();
    };

    function shallow_merge() {
      var result = {};
      $(Array.prototype.slice.call(arguments)).each(function (annotations) {
        for (var key in annotations) {
          result[key] = annotations[key];
        }
      });
      return result;
    }

    function substitute_all(example, lines) {
      return $(lines)
        .collect(function (line) {
          return substitute(example, line);
        })
        .naked();
    }

    function substitute(example, line) {
      for (var heading in example) {
        line = line.replace(new RegExp('\\' + left_placeholder_char + '\\s*' + heading + '\\s*\\' + right_placeholder_char, 'g'), StringUtils.rtrim(example[heading].join('\n')));
      }
      return line;
    }
  };
};

module.exports = FeatureParser;