Add html escaping
diff --git a/lib/char_reader.dart b/lib/char_reader.dart
index 01f9539..0f3bf98 100644
--- a/lib/char_reader.dart
+++ b/lib/char_reader.dart
@@ -5,7 +5,7 @@
   String _source;
   Iterator<int> _itr;
   int _i, _c;
-  int _line = 0, _column = 0;
+  int _line = 1, _column = 1;
 
   _CharReader(String source)
       : _source = source,
@@ -38,7 +38,7 @@
 
     if (c == _NEWLINE) {
     	_line++;
-    	_column = 0;
+    	_column = 1;
     } else {
     	_column++;
     }
@@ -51,7 +51,7 @@
   String readWhile(bool test(int charCode)) {
     
     if (peek() == _EOF)
-      throw new FormatException('Unexpected end of input: $_i');
+      throw new MustacheFormatException('Unexpected end of input.', line, column);
     
     int start = _i;
     
diff --git a/lib/mustache.dart b/lib/mustache.dart
index e09c055..517940b 100644
--- a/lib/mustache.dart
+++ b/lib/mustache.dart
@@ -6,9 +6,38 @@
 
 /// http://mustache.github.com/mustache.5.html
 
-String render(String source, values) => new Template(source).render(values);
+// TODO list.
+// moar tests.
+// html escaping.
+// strict mode :
+//   - Limit chars allowed in tagnames.
+//   - Error on null value. (Leniant mode, just print nothing)
+// Passing functions as values.
+// Async values. Support Stream, and Future values (Separate library).
+// Allow incremental parsing. I.e. read from Stream<String>, return Stream<String>.
+
+/// 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);
 
 abstract class Template {
-	factory Template(String source) => new _Template(source);
+	/// [values] can be a combination of Map, List, String. Any non-String object
+	/// will be converted using toString().
 	String render(values);
 }
+
+/// MustacheFormatException can be used to obtain the line and column numbers
+/// of the token which caused [parse] to fail.
+class MustacheFormatException implements FormatException {	
+	final String message;
+
+	/// The 1-based line number of the token where formatting error was found.
+	final int line;
+
+	/// The 1-based column number of the token where formatting error was found.
+	final int column;
+	
+	MustacheFormatException(this.message, this.line, this.column);
+	String toString() => message;
+}
diff --git a/lib/scanner.dart b/lib/scanner.dart
index 05d057d..3ab2fac 100644
--- a/lib/scanner.dart
+++ b/lib/scanner.dart
@@ -16,9 +16,9 @@
 const int _NEWLINE = 10;

 const int _EXCLAIM = 33;

 const int _QUOTE = 34;

+const int _APOS = 39;

 const int _HASH = 35;

 const int _AMP = 38;

-const int _APOS = 39;

 const int _FORWARD_SLASH = 47;

 const int _LT = 60;

 const int _GT = 62;

@@ -59,11 +59,15 @@
 

 	_expect(int expectedCharCode) {

 		int c = _read();

-		if (c != expectedCharCode) {

-			throw new FormatException('Unexpected character, '

+

+		if (c == _EOF) {

+			throw new MustacheFormatException('Unexpected end of input.', _r.line, _r.column);

+

+		} else if (c != expectedCharCode) {

+			throw new MustacheFormatException('Unexpected character, '

 				'expected: ${new String.fromCharCode(expectedCharCode)} ($expectedCharCode), '

 				'was: ${new String.fromCharCode(c)} ($c), '

-				'at: ${_r.line}:${_r.column}');

+				'at: ${_r.line}:${_r.column}', _r.line, _r.column);

 		}

 	}

 

@@ -113,7 +117,7 @@
 

 		switch(_peek()) {

 			case _EOF:

-				throw new FormatException('Unexpected EOF.');

+				throw new MustacheFormatException('Unexpected end of input.', _r.line, _r.column);

 

 			// Escaped text {{{ ... }}}

 			case _OPEN_MUSTACHE:

diff --git a/lib/template.dart b/lib/template.dart
index dae019c..843c9b8 100644
--- a/lib/template.dart
+++ b/lib/template.dart
@@ -29,10 +29,10 @@
 		} else if (t.type == _CLOSE_SECTION) {

 			//TODO in strict mode limit characters allowed in tag names.

 			if (stack.last.value != t.value) {

-				throw new FormatException('Mismatched tags, '

-					'expected: ${stack.last.value}, '

-					'was: t.value, '

-					'at: ${t.line}:${t.column}.');

+				throw new MustacheFormatException('Mismatched tag, '

+					"expected: '${stack.last.value}', "

+					"was: '${t.value}', "

+					'at: ${t.line}:${t.column}.', t.line, t.column);

 			}

 

 			stack.removeLast();

@@ -46,11 +46,19 @@
 

 class _Template implements Template {

 	_Template(String source) 

-		: _root = _parse(_scan(source));

+			: _root = _parse(_scan(source)) {

+		_htmlEscapeMap[_AMP] = '&amp;';

+		_htmlEscapeMap[_LT] = '&lt;';

+		_htmlEscapeMap[_GT] = '&gt;';

+		_htmlEscapeMap[_QUOTE] = '&quot;';

+		_htmlEscapeMap[_APOS] = '&#x27;';

+		_htmlEscapeMap[_FORWARD_SLASH] = '&#x2F;';

+	}

 

 	final _Node _root;

 	final ctl = new List(); //TODO StreamController();

 	final stack = new List();

+	final _htmlEscapeMap = new Map<int, String>();

 

 	render(values) {

 		ctl.clear();

@@ -84,9 +92,14 @@
 	}

 

 	_renderVariable(node) {

-		final value = stack.last[node.value]; //TODO optional warning if variable is null or missing.

-		final s = _htmlEscape(value.toString());

-		ctl.add(s);

+		final value = stack.last[node.value];

+

+		if (value == null) {

+			//FIXME in strict mode throw an error.

+		} else {

+			final s = _htmlEscape(value.toString());

+			ctl.add(s);

+		}

 	}

 

 	_renderSectionWithValue(node, value) {

@@ -103,8 +116,13 @@
 			_renderSectionWithValue(node, value);

 		} else if (value == true) {

 			_renderSectionWithValue(node, {});

+		} else if (value == false) {

+			// Do nothing.

+		} else if (value == null) {

+			// Do nothing.

+			// FIXME in strict mode, log an error.

 		} else {

-			print('boom!'); //FIXME

+			throw new FormatException("Invalid value type for section: '${node.value}', type: ${value.runtimeType}.");

 		}

 	}

 

@@ -117,19 +135,25 @@
 		}

 	}

 

-	/*

-	escape

-

-	& --> &amp;

-	< --> &lt;

-	> --> &gt;

-	" --> &quot;

-	' --> &#x27;     &apos; not recommended because its not in the HTML spec (See: section 24.4.1) &apos; is in the XML and XHTML specs.

-	/ --> &#x2F; 

-	*/

-	//TODO

 	String _htmlEscape(String s) {

-		return s;

+		var buffer = new StringBuffer();

+		int startIndex = 0;

+		int i = 0;

+		for (int c in s.runes) {			

+			if (c == _AMP

+					|| c == _LT

+					|| c == _GT

+					|| c == _QUOTE

+					|| c == _APOS

+					|| c == _FORWARD_SLASH) {

+				buffer.write(s.substring(startIndex, i));

+				buffer.write(_htmlEscapeMap[c]);

+				startIndex = i + 1;

+			}

+			i++;

+		}

+		buffer.write(s.substring(startIndex));			

+		return buffer.toString();

 	}

 }

 

diff --git a/test/mustache_test.dart b/test/mustache_test.dart
index 7bba466..5176a48 100644
--- a/test/mustache_test.dart
+++ b/test/mustache_test.dart
@@ -3,12 +3,171 @@
 import 'package:unittest/unittest.dart';
 import 'package:mustache/mustache.dart';
 
+const MISMATCHED_TAG = 'Mismatched tag';
+const UNEXPECTED_EOF = 'Unexpected end of input';
+const INVALID_VALUE_SECTION = 'Invalid value type for section';
+
 main() {
-	test('1', () {
-		var output = render(
-			'{{#section}}_{{var}}_{{/section}}',
-			{"section": {"var": "bob"}}
-		);
-		expect(output, equals('_bob_'));
+	group('Section', () {
+		test('Map', () {
+			var output = parse('{{#section}}_{{var}}_{{/section}}')
+				.render({"section": {"var": "bob"}});
+			expect(output, equals('_bob_'));
+		});
+		test('List', () {
+			var output = parse('{{#section}}_{{var}}_{{/section}}')
+				.render({"section": [{"var": "bob"}, {"var": "jim"}]});
+			expect(output, equals('_bob__jim_'));
+		});
+		test('Empty List', () {
+			var output = parse('{{#section}}_{{var}}_{{/section}}')
+				.render({"section": []});
+			expect(output, equals(''));
+		});
+		test('False', () {
+			var output = parse('{{#section}}_{{var}}_{{/section}}')
+				.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));
+		});
+		test('True', () {
+			var output = parse('{{#section}}_ok_{{/section}}')
+				.render({"section": true});
+			expect(output, equals('_ok_'));
+		});
+	});
+
+	group('Inverse Section', () {
+		test('Map', () {
+			var output = parse('{{^section}}_{{var}}_{{/section}}')
+				.render({"section": {"var": "bob"}});
+			expect(output, equals(''));
+		});
+		test('List', () {
+			var output = parse('{{^section}}_{{var}}_{{/section}}')
+				.render({"section": [{"var": "bob"}, {"var": "jim"}]});
+			expect(output, equals(''));
+		});
+		test('Empty List', () {
+			var output = parse('{{^section}}_ok_{{/section}}')
+				.render({"section": []});
+			expect(output, equals('_ok_'));
+		});
+		test('False', () {
+			var output = parse('{{^section}}_ok_{{/section}}')
+				.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_SECTION));
+		});
+		test('True', () {
+			var output = parse('{{^section}}_ok_{{/section}}')
+				.render({"section": true});
+			expect(output, equals(''));
+		});
+	});
+
+	group('Html escape', () {
+
+		test('Escape at start', () {
+			var output = parse('_{{var}}_')
+				.render({"var": "&."});
+			expect(output, equals('_&amp;._'));
+		});
+
+		test('Escape at end', () {
+			var output = parse('_{{var}}_')
+				.render({"var": ".&"});
+			expect(output, equals('_.&amp;_'));
+		});
+
+		test('&', () {
+			var output = parse('_{{var}}_')
+				.render({"var": "&"});
+			expect(output, equals('_&amp;_'));
+		});
+
+		test('<', () {
+			var output = parse('_{{var}}_')
+				.render({"var": "<"});
+			expect(output, equals('_&lt;_'));
+		});
+
+		test('>', () {
+			var output = parse('_{{var}}_')
+				.render({"var": ">"});
+			expect(output, equals('_&gt;_'));
+		});
+
+		test('"', () {
+			var output = parse('_{{var}}_')
+				.render({"var": '"'});
+			expect(output, equals('_&quot;_'));
+		});
+
+		test("'", () {
+			var output = parse('_{{var}}_')
+				.render({"var": "'"});
+			expect(output, equals('_&#x27;_'));
+		});
+
+		test("/", () {
+			var output = parse('_{{var}}_')
+				.render({"var": "/"});
+			expect(output, equals('_&#x2F;_'));
+		});
+
+	});
+
+	group('Invalid format', () {
+		test('Mismatched tag', () {
+			var source = '{{#section}}_{{var}}_{{/not_section}}';
+			var ex = renderFail(source, {"section": {"var": "bob"}});
+			expectFail(ex, 1, 25, 'Mismatched tag');
+		});
+		test('Unexpected EOF', () {
+			var source = '{{#section}}_{{var}}_{{/not_section';
+			var ex = renderFail(source, {"section": {"var": "bob"}});
+			expectFail(ex, 1, source.length + 2, UNEXPECTED_EOF);
+		});
 	});
 }
+
+renderFail(source, values) {
+	try {
+		parse(source).render(values);
+		return null;
+	} catch (e) {
+		return e;
+	}	
+}
+
+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 (msgStartsWith != null)
+			expect(ex.message, startsWith(msgStartsWith));
+}
\ No newline at end of file