Add strict/lenient mode
diff --git a/check.sh b/check.sh
index 04d5741..0bdc6cf 100755
--- a/check.sh
+++ b/check.sh
@@ -1,4 +1,10 @@
+#!/bin/bash
+
+# Abort if non-zero code returned.
+set -e
+
dart_analyzer --type-checks-for-inferred-types lib/mustache.dart
+dart_analyzer --type-checks-for-inferred-types test/mustache_test.dart
dart --checked test/mustache_test.dart
diff --git a/lib/mustache.dart b/lib/mustache.dart
index d1b93f5..cdd604b 100644
--- a/lib/mustache.dart
+++ b/lib/mustache.dart
@@ -9,13 +9,14 @@
/// Returns a [Template] which can be used to render the template with substituted
/// values.
/// Throws [FormatException] if the syntax of the source is invalid.
-Template parse(String source) => new _Template(source);
+Template parse(String source,
+ {bool lenient : false}) => _parse(source, lenient: lenient);
/// A Template can be used multiple times to [render] content with different values.
abstract class Template {
/// [values] can be a combination of Map, List, String. Any non-String object
/// will be converted using toString().
- String render(values);
+ String render(values, {bool lenient : false});
}
/// MustacheFormatException can be used to obtain the line and column numbers
diff --git a/lib/scanner.dart b/lib/scanner.dart
index 3ab2fac..5bd1c6a 100644
--- a/lib/scanner.dart
+++ b/lib/scanner.dart
@@ -1,6 +1,6 @@
part of mustache;
-List<_Token> _scan(String source) => new _Scanner(source).scan();
+List<_Token> _scan(String source, bool lenient) => new _Scanner(source).scan();
const int _TEXT = 1;
const int _VARIABLE = 2;
diff --git a/lib/template.dart b/lib/template.dart
index f2927ad..31102ee 100644
--- a/lib/template.dart
+++ b/lib/template.dart
@@ -1,33 +1,30 @@
part of mustache;
-class _Node {
- _Node(this.type, this.value, this.line, this.column);
- _Node.fromToken(_Token token)
- : type = token.type,
- value = token.value,
- line = token.line,
- column = token.column;
- final int type;
- final String value;
- final int line;
- final int column;
- final List<_Node> children = new List<_Node>();
- String toString() => '_Node: ${tokenTypeString(type)}';
+final RegExp _validTag = new RegExp(r'^[0-9a-zA-Z\_\-]+$');
+
+Template _parse(String source, {bool lenient : false}) {
+ var tokens = _scan(source, lenient);
+ var ast = _parseTokens(tokens, lenient);
+ return new _Template(ast, lenient);
}
-_Node _parse(List<_Token> tokens) {
+_Node _parseTokens(List<_Token> tokens, bool lenient) {
var stack = new List<_Node>()..add(new _Node(_OPEN_SECTION, 'root', 0, 0));
for (var t in tokens) {
if (t.type == _TEXT || t.type == _VARIABLE) {
+ if (t.type == _VARIABLE)
+ _checkTagChars(t, lenient);
stack.last.children.add(new _Node.fromToken(t));
+
} else if (t.type == _OPEN_SECTION || t.type == _OPEN_INV_SECTION) {
- //TODO in strict mode limit characters allowed in tag names.
+ _checkTagChars(t, lenient);
var child = new _Node.fromToken(t);
stack.last.children.add(child);
stack.add(child);
} else if (t.type == _CLOSE_SECTION) {
- //TODO in strict mode limit characters allowed in tag names.
+ _checkTagChars(t, lenient);
+
if (stack.last.value != t.value) {
throw new MustacheFormatException('Mismatched tag, '
"expected: '${stack.last.value}', "
@@ -36,6 +33,7 @@
}
stack.removeLast();
+
} else {
throw new UnimplementedError();
}
@@ -44,9 +42,17 @@
return stack.last;
}
+_checkTagChars(_Token t, bool lenient) {
+ if (!lenient && !_validTag.hasMatch(t.value)) {
+ throw new MustacheFormatException(
+ 'Tag contained invalid characters in name, '
+ 'allowed: 0-9, a-z, A-Z, underscore, and minus, '
+ 'at: ${t.line}:${t.column}.', t.line, t.column);
+ }
+}
+
class _Template implements Template {
- _Template(String source)
- : _root = _parse(_scan(source)) {
+ _Template(this._root, this._lenient) {
_htmlEscapeMap[_AMP] = '&';
_htmlEscapeMap[_LT] = '<';
_htmlEscapeMap[_GT] = '>';
@@ -56,11 +62,12 @@
}
final _Node _root;
- final _buffer = new StringBuffer();
- final _stack = new List();
- final _htmlEscapeMap = new Map<int, String>();
+ final StringBuffer _buffer = new StringBuffer();
+ final List _stack = new List();
+ final Map _htmlEscapeMap = new Map<int, String>();
+ final bool _lenient;
- render(values) {
+ render(values, {bool lenient : false}) {
_buffer.clear();
_stack.clear();
_stack.add(values);
@@ -97,9 +104,12 @@
_renderVariable(node) {
final value = _stack.last[node.value];
-
if (value == null) {
- //FIXME in strict mode throw an error.
+ if (!_lenient)
+ throw new MustacheFormatException(
+ 'Value was null or missing, '
+ 'variable: ${node.value}, '
+ 'at: ${node.line}:${node.column}.', node.line, node.column);
} else {
_write(_htmlEscape(value.toString()));
}
@@ -122,24 +132,38 @@
} else if (value == false) {
// Do nothing.
} else if (value == null) {
- // Do nothing.
- // FIXME in strict mode, log an error.
+ if (!_lenient)
+ throw new MustacheFormatException(
+ 'Value was null or missing, '
+ 'section: ${node.value}, '
+ 'at: ${node.line}:${node.column}.', node.line, node.column);
} else {
- throw new FormatException("Invalid value type for section: '${node.value}', type: ${value.runtimeType}.");
+ throw new MustacheFormatException(
+ 'Invalid value type for section, '
+ 'section: ${node.value}, '
+ 'type: ${value.runtimeType}, '
+ 'at: ${node.line}:${node.column}.', node.line, node.column);
}
}
_renderInvSection(node) {
final value = _stack.last[node.value];
- if ((value is List && value.isEmpty)
- || value == null
- || value == false) {
- // FIXME in strict mode, log an error if value is null.
+ if ((value is List && value.isEmpty) || value == false) {
_renderSectionWithValue(node, {});
} else if (value == true || value is Map || value is List) {
// Do nothing.
+ } else if (value == null) {
+ if (!_lenient)
+ throw new MustacheFormatException(
+ 'Value was null or missing, '
+ 'inverse-section: ${node.value}, '
+ 'at: ${node.line}:${node.column}.', node.line, node.column);
} else {
- throw new FormatException("Invalid value type for inverse section: '${node.value}', type: ${value.runtimeType}.");
+ throw new MustacheFormatException(
+ 'Invalid value type for inverse section, '
+ 'section: ${node.value}, '
+ 'type: ${value.runtimeType}, '
+ 'at: ${node.line}:${node.column}.', node.line, node.column);
}
}
@@ -173,3 +197,18 @@
visitor(node);
}
}
+
+class _Node {
+ _Node(this.type, this.value, this.line, this.column);
+ _Node.fromToken(_Token token)
+ : type = token.type,
+ value = token.value,
+ line = token.line,
+ column = token.column;
+ final int type;
+ final String value;
+ final int line;
+ final int column;
+ final List<_Node> children = new List<_Node>();
+ String toString() => '_Node: ${tokenTypeString(type)}';
+}
\ No newline at end of file
diff --git a/test/mustache_test.dart b/test/mustache_test.dart
index 5ab27fa..cde7bb8 100644
--- a/test/mustache_test.dart
+++ b/test/mustache_test.dart
@@ -5,8 +5,10 @@
const MISMATCHED_TAG = 'Mismatched tag';
const UNEXPECTED_EOF = 'Unexpected end of input';
-const INVALID_VALUE_SECTION = 'Invalid value type for section';
-const INVALID_VALUE_INV_SECTION = 'Invalid value type for inverse section';
+const BAD_VALUE_SECTION = 'Invalid value type for section';
+const BAD_VALUE_INV_SECTION = 'Invalid value type for inverse section';
+const BAD_TAG_NAME = 'Tag contained invalid characters in name';
+const VALUE_NULL = 'Value was null or missing';
main() {
group('Section', () {
@@ -30,17 +32,12 @@
.render({"section": false});
expect(output, equals(''));
});
- test('Null', () {
- var output = parse('{{#section}}_{{var}}_{{/section}}')
- .render({"section": null});
- expect(output, equals(''));
- });
test('Invalid value', () {
var ex = renderFail(
'{{#section}}_{{var}}_{{/section}}',
{"section": 42});
expect(ex is FormatException, isTrue);
- expect(ex.message, startsWith(INVALID_VALUE_SECTION));
+ expect(ex.message, startsWith(BAD_VALUE_SECTION));
});
test('True', () {
var output = parse('{{#section}}_ok_{{/section}}')
@@ -48,12 +45,12 @@
expect(output, equals('_ok_'));
});
test('Nested', () {
- var output = parse('{{#section}}.{{var}}.{{#nested}}_{{nested_var}}_{{/nested}}.{{/section}}')
+ var output = parse('{{#section}}.{{var}}.{{#nested}}_{{nestedvar}}_{{/nested}}.{{/section}}')
.render({"section": {
"var": "bob",
"nested": [
- {"nested_var": "jim"},
- {"nested_var": "sally"}
+ {"nestedvar": "jim"},
+ {"nestedvar": "sally"}
]
}});
expect(output, equals('.bob._jim__sally_.'));
@@ -81,17 +78,12 @@
.render({"section": false});
expect(output, equals('_ok_'));
});
- test('Null', () {
- var output = parse('{{^section}}_ok_{{/section}}')
- .render({"section": null});
- expect(output, equals('_ok_'));
- });
test('Invalid value', () {
var ex = renderFail(
'{{^section}}_{{var}}_{{/section}}',
{"section": 42});
expect(ex is FormatException, isTrue);
- expect(ex.message, startsWith(INVALID_VALUE_INV_SECTION));
+ expect(ex.message, startsWith(BAD_VALUE_INV_SECTION));
});
test('True', () {
var output = parse('{{^section}}_ok_{{/section}}')
@@ -154,15 +146,84 @@
group('Invalid format', () {
test('Mismatched tag', () {
- var source = '{{#section}}_{{var}}_{{/not_section}}';
+ var source = '{{#section}}_{{var}}_{{/notsection}}';
var ex = renderFail(source, {"section": {"var": "bob"}});
expectFail(ex, 1, 25, 'Mismatched tag');
});
+
test('Unexpected EOF', () {
- var source = '{{#section}}_{{var}}_{{/not_section';
+ var source = '{{#section}}_{{var}}_{{/section';
var ex = renderFail(source, {"section": {"var": "bob"}});
expectFail(ex, 1, source.length + 2, UNEXPECTED_EOF);
});
+
+ test('Bad tag name, open section', () {
+ var source = r'{{#section$%$^%}}_{{var}}_{{/section}}';
+ var ex = renderFail(source, {"section": {"var": "bob"}});
+ expectFail(ex, null, null, BAD_TAG_NAME);
+ });
+
+ test('Bad tag name, close section', () {
+ var source = r'{{#section}}_{{var}}_{{/section$%$^%}}';
+ var ex = renderFail(source, {"section": {"var": "bob"}});
+ expectFail(ex, null, null, BAD_TAG_NAME);
+ });
+
+ test('Bad tag name, variable', () {
+ var source = r'{{#section}}_{{var$%$^%}}_{{/section}}';
+ var ex = renderFail(source, {"section": {"var": "bob"}});
+ expectFail(ex, null, null, BAD_TAG_NAME);
+ });
+
+ test('Null section', () {
+ var source = '{{#section}}_{{var}}_{{/section}}';
+ var ex = renderFail(source, {'section': null});
+ expectFail(ex, null, null, VALUE_NULL);
+ });
+
+ test('Null inverse section', () {
+ var source = '{{^section}}_{{var}}_{{/section}}';
+ var ex = renderFail(source, {'section': null});
+ expectFail(ex, null, null, VALUE_NULL);
+ });
+
+ test('Null variable', () {
+ var source = '{{#section}}_{{var}}_{{/section}}';
+ var ex = renderFail(source, {'section': {'var': null}});
+ expectFail(ex, null, null, VALUE_NULL);
+ });
+ });
+
+ group('Lenient', () {
+ test('Odd section name', () {
+ var output = parse(r'{{#section$%$^%}}_{{var}}_{{/section$%$^%}}', lenient: true)
+ .render({r'section$%$^%': {'var': 'bob'}}, lenient: true);
+ expect(output, equals('_bob_'));
+ });
+
+ test('Odd variable name', () {
+ var output = parse(r'{{#section}}_{{var$%$^%}}_{{/section}}', lenient: true)
+ .render({'section': {r'var$%$^%': 'bob'}}, lenient: true);
+ });
+
+ test('Null variable', () {
+ var output = parse(r'{{#section}}_{{var}}_{{/section}}', lenient: true)
+ .render({'section': {'var': null}}, lenient: true);
+ expect(output, equals('__'));
+ });
+
+ test('Null section', () {
+ var output = parse('{{#section}}_{{var}}_{{/section}}', lenient: true)
+ .render({"section": null}, lenient: true);
+ expect(output, equals(''));
+ });
+
+ test('Null inverse section', () {
+ var output = parse('{{^section}}_{{var}}_{{/section}}', lenient: true)
+ .render({"section": null}, lenient: true);
+ expect(output, equals(''));
+ });
+
});
}
@@ -179,8 +240,10 @@
expectFail(ex, int line, int column, [String msgStartsWith]) {
expect(ex, isFormatException);
expect(ex is MustacheFormatException, isTrue);
- expect(ex.line, equals(line));
- expect(ex.column, equals(column));
+ if (line != null)
+ expect(ex.line, equals(line));
+ if (column != null)
+ expect(ex.column, equals(column));
if (msgStartsWith != null)
expect(ex.message, startsWith(msgStartsWith));
}
\ No newline at end of file