diet-yadda
Version:
A true BDD framework for JavaScript - slimmed down
577 lines (483 loc) • 21.6 kB
JavaScript
/*
* Copyright 2010 Acuminous Ltd / Energized Work Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* jslint node: true */
;
var $ = require('../Array');
var fn = require('../fn');
var StringUtils = require('../StringUtils');
var Localisation = require('../localisation');
var FeatureParser = function(options) {
/* jslint shadow: true */
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;
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) {
/* jslint boss: true */
var match;
var line_number = index + 1;
try {
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) {
/* jslint shadow: true */
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) {
/* jslint boss: true */
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: end_description,
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');
stashed_annotations.stash(annotation.key, annotation.value);
}
function capture_description(event, text) {
description.push(StringUtils.trim(text));
}
function end_description(event, text, line_number) {
handlers.unregister('text');
handlers.register('blank', fn.noop);
}
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
});
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');
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
});
var _this = this;
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;