From d2e700edd1bd67ea752afbff379092f25ccdb92f Mon Sep 17 00:00:00 2001 From: Tobe O Date: Fri, 29 Sep 2017 18:39:37 -0400 Subject: [PATCH] core nearly done --- .gitignore | 51 ++++ .idea/misc.xml | 6 + .idea/modules.xml | 8 + .travis.yml | 2 + README.md | 2 + angel_jael/.gitignore | 14 + angel_jael/analysis_options.yaml | 2 + jael.iml | 14 + jael/.gitignore | 14 + jael/README.md | 50 ++++ jael/analysis_options.yaml | 2 + jael/lib/jael.dart | 4 + jael/lib/src/ast/array.dart | 41 +++ jael/lib/src/ast/ast.dart | 17 ++ jael/lib/src/ast/ast_node.dart | 5 + jael/lib/src/ast/attribute.dart | 19 ++ jael/lib/src/ast/binary.dart | 47 +++ jael/lib/src/ast/call.dart | 55 ++++ jael/lib/src/ast/document.dart | 59 ++++ jael/lib/src/ast/element.dart | 90 ++++++ jael/lib/src/ast/error.dart | 21 ++ jael/lib/src/ast/expression.dart | 8 + jael/lib/src/ast/identifier.dart | 23 ++ jael/lib/src/ast/interpolation.dart | 18 ++ jael/lib/src/ast/map.dart | 53 ++++ jael/lib/src/ast/member.dart | 23 ++ jael/lib/src/ast/new.dart | 31 ++ jael/lib/src/ast/number.dart | 47 +++ jael/lib/src/ast/string.dart | 74 +++++ jael/lib/src/ast/token.dart | 57 ++++ jael/lib/src/renderer.dart | 183 ++++++++++++ jael/lib/src/text/parselet/infix.dart | 127 ++++++++ jael/lib/src/text/parselet/parselet.dart | 14 + jael/lib/src/text/parselet/prefix.dart | 142 +++++++++ jael/lib/src/text/parser.dart | 362 +++++++++++++++++++++++ jael/lib/src/text/scanner.dart | 177 +++++++++++ jael/pubspec.yaml | 15 + jael/test/render/render_test.dart | 175 +++++++++++ jael/test/text/common.dart | 24 ++ jael/test/text/scan_test.dart | 62 ++++ jael_preprocessor/.gitignore | 14 + jael_preprocessor/analysis_options.yaml | 2 + travis.sh | 3 + 43 files changed, 2157 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .travis.yml create mode 100644 README.md create mode 100644 angel_jael/.gitignore create mode 100644 angel_jael/analysis_options.yaml create mode 100644 jael.iml create mode 100644 jael/.gitignore create mode 100644 jael/README.md create mode 100644 jael/analysis_options.yaml create mode 100644 jael/lib/jael.dart create mode 100644 jael/lib/src/ast/array.dart create mode 100644 jael/lib/src/ast/ast.dart create mode 100644 jael/lib/src/ast/ast_node.dart create mode 100644 jael/lib/src/ast/attribute.dart create mode 100644 jael/lib/src/ast/binary.dart create mode 100644 jael/lib/src/ast/call.dart create mode 100644 jael/lib/src/ast/document.dart create mode 100644 jael/lib/src/ast/element.dart create mode 100644 jael/lib/src/ast/error.dart create mode 100644 jael/lib/src/ast/expression.dart create mode 100644 jael/lib/src/ast/identifier.dart create mode 100644 jael/lib/src/ast/interpolation.dart create mode 100644 jael/lib/src/ast/map.dart create mode 100644 jael/lib/src/ast/member.dart create mode 100644 jael/lib/src/ast/new.dart create mode 100644 jael/lib/src/ast/number.dart create mode 100644 jael/lib/src/ast/string.dart create mode 100644 jael/lib/src/ast/token.dart create mode 100644 jael/lib/src/renderer.dart create mode 100644 jael/lib/src/text/parselet/infix.dart create mode 100644 jael/lib/src/text/parselet/parselet.dart create mode 100644 jael/lib/src/text/parselet/prefix.dart create mode 100644 jael/lib/src/text/parser.dart create mode 100644 jael/lib/src/text/scanner.dart create mode 100644 jael/pubspec.yaml create mode 100644 jael/test/render/render_test.dart create mode 100644 jael/test/text/common.dart create mode 100644 jael/test/text/scan_test.dart create mode 100644 jael_preprocessor/.gitignore create mode 100644 jael_preprocessor/analysis_options.yaml create mode 100644 travis.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..37e7b7e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# Created by .ignore support plugin (hsz.mobi) +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/dictionaries + +# Sensitive or high-churn files: +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.xml +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml + +# Gradle: +.idea/**/gradle.xml +.idea/**/libraries + +# CMake +cmake-build-debug/ + +# Mongo Explorer plugin: +.idea/**/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..639900d1 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..d171efec --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..58ef8a07 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,2 @@ +language: dart +script: bash ./travis.sh diff --git a/README.md b/README.md new file mode 100644 index 00000000..00bcf26d --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# jael +A simple server-side HTML templating engine for Dart. \ No newline at end of file diff --git a/angel_jael/.gitignore b/angel_jael/.gitignore new file mode 100644 index 00000000..4353a22c --- /dev/null +++ b/angel_jael/.gitignore @@ -0,0 +1,14 @@ +# Created by .ignore support plugin (hsz.mobi) +### Dart template +# See https://www.dartlang.org/tools/private-files.html + +# Files and directories created by pub +.packages +.pub/ +build/ +# If you're building an application, you may want to check-in your pubspec.lock +pubspec.lock + +# Directory created by dartdoc +# If you don't generate documentation locally you can remove this line. +doc/api/ diff --git a/angel_jael/analysis_options.yaml b/angel_jael/analysis_options.yaml new file mode 100644 index 00000000..518eb901 --- /dev/null +++ b/angel_jael/analysis_options.yaml @@ -0,0 +1,2 @@ +analyzer: + strong-mode: true \ No newline at end of file diff --git a/jael.iml b/jael.iml new file mode 100644 index 00000000..d1021572 --- /dev/null +++ b/jael.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jael/.gitignore b/jael/.gitignore new file mode 100644 index 00000000..4353a22c --- /dev/null +++ b/jael/.gitignore @@ -0,0 +1,14 @@ +# Created by .ignore support plugin (hsz.mobi) +### Dart template +# See https://www.dartlang.org/tools/private-files.html + +# Files and directories created by pub +.packages +.pub/ +build/ +# If you're building an application, you may want to check-in your pubspec.lock +pubspec.lock + +# Directory created by dartdoc +# If you don't generate documentation locally you can remove this line. +doc/api/ diff --git a/jael/README.md b/jael/README.md new file mode 100644 index 00000000..ee69cfe1 --- /dev/null +++ b/jael/README.md @@ -0,0 +1,50 @@ +# jael +[![Pub](https://img.shields.io/pub/v/jael.svg)](https://pub.dartlang.org/packages/jael) +[![build status](https://travis-ci.org/angel-dart/jael.svg)](https://travis-ci.org/angel-dart/jael) + +A simple server-side HTML templating engine for Dart. + +[See documentation.](https://github.com/angel-dart/jael/wiki) + +# Installation +In your `pubspec.yaml`: + +```yaml +dependencies: + jael: ^1.0.0-alpha +``` + +# API +The core `jael` package exports classes for parsing Jael templates, +an AST library, and a `Renderer` class that generates HTML on-the-fly. + +```dart +import 'package:code_buffer/code_buffer.dart'; +import 'package:jael/jael.dart' as jael; +import 'package:symbol_table/symbol_table.dart'; + +void myFunction() { + const template = ''' + + +

Hello

+ + + +'''; + + var buf = new CodeBuffer(); + var document = jael.parseDocument(template, sourceUrl: 'test.jl'); + var scope = new SymbolTable(values: { + 'profile': { + 'avatar': 'thosakwe.png', + } + }); + + const jael.Renderer().render(document, buf, scope); + print(buf); +} +``` + +Pre-processing (i.e. handling of blocks and includes) is handled +by `package:jael_processor.`. \ No newline at end of file diff --git a/jael/analysis_options.yaml b/jael/analysis_options.yaml new file mode 100644 index 00000000..518eb901 --- /dev/null +++ b/jael/analysis_options.yaml @@ -0,0 +1,2 @@ +analyzer: + strong-mode: true \ No newline at end of file diff --git a/jael/lib/jael.dart b/jael/lib/jael.dart new file mode 100644 index 00000000..b94f25be --- /dev/null +++ b/jael/lib/jael.dart @@ -0,0 +1,4 @@ +export 'src/ast/ast.dart'; +export 'src/text/parser.dart'; +export 'src/text/scanner.dart'; +export 'src/renderer.dart'; \ No newline at end of file diff --git a/jael/lib/src/ast/array.dart b/jael/lib/src/ast/array.dart new file mode 100644 index 00000000..aff752b3 --- /dev/null +++ b/jael/lib/src/ast/array.dart @@ -0,0 +1,41 @@ +import 'package:source_span/source_span.dart'; +import 'expression.dart'; +import 'token.dart'; + +class Array extends Expression { + final Token lBracket, rBracket; + final List items; + + Array(this.lBracket, this.rBracket, this.items); + + @override + compute(scope) => items.map((e) => e.compute(scope)).toList(); + + @override + FileSpan get span { + return items + .fold(lBracket.span, (out, i) => out.expand(i.span)) + .expand(rBracket.span); + } +} + +class IndexerExpression extends Expression { + final Expression target, indexer; + final Token lBracket, rBracket; + + IndexerExpression(this.target, this.lBracket, this.indexer, this.rBracket); + + @override + FileSpan get span { + return target.span + .expand(lBracket.span) + .expand(indexer.span) + .expand(rBracket.span); + } + + @override + compute(scope) { + var a = target.compute(scope), b = indexer.compute(scope); + return a[b]; + } +} diff --git a/jael/lib/src/ast/ast.dart b/jael/lib/src/ast/ast.dart new file mode 100644 index 00000000..8d32a264 --- /dev/null +++ b/jael/lib/src/ast/ast.dart @@ -0,0 +1,17 @@ +export 'array.dart'; +export 'ast_node.dart'; +export 'attribute.dart'; +export 'binary.dart'; +export 'call.dart'; +export 'document.dart'; +export 'element.dart'; +export 'error.dart'; +export 'expression.dart'; +export 'identifier.dart'; +export 'interpolation.dart'; +export 'map.dart'; +export 'member.dart'; +export 'new.dart'; +export 'number.dart'; +export 'string.dart'; +export 'token.dart'; \ No newline at end of file diff --git a/jael/lib/src/ast/ast_node.dart b/jael/lib/src/ast/ast_node.dart new file mode 100644 index 00000000..2ff17149 --- /dev/null +++ b/jael/lib/src/ast/ast_node.dart @@ -0,0 +1,5 @@ +import 'package:source_span/source_span.dart'; + +abstract class AstNode { + FileSpan get span; +} \ No newline at end of file diff --git a/jael/lib/src/ast/attribute.dart b/jael/lib/src/ast/attribute.dart new file mode 100644 index 00000000..28b02fce --- /dev/null +++ b/jael/lib/src/ast/attribute.dart @@ -0,0 +1,19 @@ +import 'package:source_span/source_span.dart'; +import 'ast_node.dart'; +import 'expression.dart'; +import 'identifier.dart'; +import 'token.dart'; + +class Attribute extends AstNode { + final Identifier name; + final Token equals; + final Expression value; + + Attribute(this.name, this.equals, this.value); + + @override + FileSpan get span { + if (equals == null) return name.span; + return name.span.expand(equals.span).expand(value.span); + } +} diff --git a/jael/lib/src/ast/binary.dart b/jael/lib/src/ast/binary.dart new file mode 100644 index 00000000..d8bef3cb --- /dev/null +++ b/jael/lib/src/ast/binary.dart @@ -0,0 +1,47 @@ +import 'package:source_span/source_span.dart'; +import 'expression.dart'; +import 'token.dart'; + +class BinaryExpression extends Expression { + final Expression left, right; + final Token operator; + + BinaryExpression(this.left, this.operator, this.right); + + @override + compute(scope) { + var l = left.compute(scope), r = right.compute(scope); + + switch (operator?.type) { + case TokenType.asterisk: + return l * r; + case TokenType.slash: + return l / r; + case TokenType.plus: + if (l is String || r is String) return l.toString() + r.toString(); + return l + r; + case TokenType.minus: + return l - r; + case TokenType.lt: + return l < r; + case TokenType.gt: + return l > r; + case TokenType.lte: + return l <= r; + case TokenType.gte: + return l >= r; + case TokenType.equ: + return l == r; + case TokenType.nequ: + return l != r; + case TokenType.elvis: + return l ?? r; + default: + throw new UnsupportedError( + 'Unsupported binary operator: "${operator?.span ?? ""}".'); + } + } + + @override + FileSpan get span => left.span.expand(operator.span).expand(right.span); +} diff --git a/jael/lib/src/ast/call.dart b/jael/lib/src/ast/call.dart new file mode 100644 index 00000000..6c10af23 --- /dev/null +++ b/jael/lib/src/ast/call.dart @@ -0,0 +1,55 @@ +import 'package:source_span/source_span.dart'; +import 'package:symbol_table/symbol_table.dart'; +import 'ast_node.dart'; +import 'expression.dart'; +import 'identifier.dart'; +import 'token.dart'; + +class Call extends Expression { + final Expression target; + final Token lParen, rParen; + final List arguments; + final List namedArguments; + + Call(this.target, this.lParen, this.rParen, this.arguments, + this.namedArguments); + + @override + FileSpan get span { + return arguments + .fold(lParen.span, (out, a) => out.expand(a.span)) + .expand(namedArguments.fold( + lParen.span, (out, a) => out.expand(a.span))) + .expand(rParen.span); + } + + List computePositional(SymbolTable scope) => arguments.map((e) => e.compute(scope)).toList(); + + Map computeNamed(SymbolTable scope) { + return namedArguments.fold>({}, (out, a) { + return out..[new Symbol(a.name.name)] = a.value.compute(scope); + }); + } + + @override + compute(scope) { + var callee = target.compute(scope); + var args = computePositional(scope); + var named = computeNamed(scope); + + return Function.apply(callee, args, named); + } +} + +class NamedArgument extends AstNode { + final Identifier name; + final Token colon; + final Expression value; + + NamedArgument(this.name, this.colon, this.value); + + @override + FileSpan get span { + return name.span.expand(colon.span).expand(value.span); + } +} diff --git a/jael/lib/src/ast/document.dart b/jael/lib/src/ast/document.dart new file mode 100644 index 00000000..e6324f65 --- /dev/null +++ b/jael/lib/src/ast/document.dart @@ -0,0 +1,59 @@ +import 'package:source_span/source_span.dart'; +import 'ast_node.dart'; +import 'element.dart'; +import 'identifier.dart'; +import 'string.dart'; +import 'token.dart'; + +class Document extends AstNode { + final Doctype doctype; + final Element root; + + Document(this.doctype, this.root); + + @override + FileSpan get span { + if (doctype == null) return root.span; + return doctype.span.expand(root.span); + } +} + +class HtmlComment extends ElementChild { + final Token htmlComment; + + HtmlComment(this.htmlComment); + + @override + FileSpan get span => htmlComment.span; +} + +class Text extends ElementChild { + final Token text; + + Text(this.text); + + @override + FileSpan get span => text.span; +} + +class Doctype extends AstNode { + final Token lt, doctype, gt; + final Identifier html, public; + final StringLiteral name, url; + + Doctype(this.lt, this.doctype, this.html, this.public, this.name, this.url, + this.gt); + + @override + FileSpan get span { + if (public == null) + return lt.span.expand(doctype.span).expand(html.span).expand(gt.span); + return lt.span + .expand(doctype.span) + .expand(html.span) + .expand(public.span) + .expand(name.span) + .expand(url.span) + .expand(gt.span); + } +} diff --git a/jael/lib/src/ast/element.dart b/jael/lib/src/ast/element.dart new file mode 100644 index 00000000..7e184a2a --- /dev/null +++ b/jael/lib/src/ast/element.dart @@ -0,0 +1,90 @@ +import 'package:source_span/source_span.dart'; +import 'ast_node.dart'; +import 'attribute.dart'; +import 'identifier.dart'; +import 'token.dart'; + +abstract class ElementChild extends AstNode {} + +class TextNode extends ElementChild { + final Token text; + + TextNode(this.text); + + @override + FileSpan get span => text.span; +} + +abstract class Element extends ElementChild { + static const List selfClosing = const [ + 'base', + 'basefont', + 'frame', + 'link', + 'meta', + 'area', + 'br', + 'col', + 'hr', + 'img', + 'input', + 'param', + ]; + + Identifier get tagName; + Iterable get attributes; + Iterable get children; +} + +class SelfClosingElement extends Element { + final Token lt, slash, gt; + + final Identifier tagName; + + final Iterable attributes; + + @override + Iterable get children => []; + + SelfClosingElement( + this.lt, this.tagName, this.attributes, this.slash, this.gt); + + @override + FileSpan get span { + var start = attributes.fold( + lt.span.expand(tagName.span), (out, a) => out.expand(a.span)); + return slash != null + ? start.expand(slash.span).expand(gt.span) + : start.expand(gt.span); + } +} + +class RegularElement extends Element { + final Token lt, gt, lt2, slash, gt2; + + final Identifier tagName, tagName2; + + final Iterable attributes; + + final Iterable children; + + RegularElement(this.lt, this.tagName, this.attributes, this.gt, this.children, + this.lt2, this.slash, this.tagName2, this.gt2); + + @override + FileSpan get span { + var openingTag = attributes + .fold( + lt.span.expand(tagName.span), (out, a) => out.expand(a.span)) + .expand(gt.span); + + if (gt2 == null) return openingTag; + + return children + .fold(openingTag, (out, c) => out.expand(c.span)) + .expand(lt2.span) + .expand(slash.span) + .expand(tagName2.span) + .expand(gt2.span); + } +} diff --git a/jael/lib/src/ast/error.dart b/jael/lib/src/ast/error.dart new file mode 100644 index 00000000..350ba67d --- /dev/null +++ b/jael/lib/src/ast/error.dart @@ -0,0 +1,21 @@ +import 'package:source_span/source_span.dart'; + +class JaelError { + final JaelErrorSeverity severity; + final String message; + final FileSpan span; + + JaelError(this.severity, this.message, this.span); + + @override + String toString() { + var label = severity == JaelErrorSeverity.warning ? 'warning' : 'error'; + return '$label: ${span.start.toolString}: $message\n' + + span.highlight(color: true); + } +} + +enum JaelErrorSeverity { + warning, + error, +} diff --git a/jael/lib/src/ast/expression.dart b/jael/lib/src/ast/expression.dart new file mode 100644 index 00000000..5ad70635 --- /dev/null +++ b/jael/lib/src/ast/expression.dart @@ -0,0 +1,8 @@ +import 'package:symbol_table/symbol_table.dart'; +import 'ast_node.dart'; + +abstract class Expression extends AstNode { + compute(SymbolTable scope); +} + +abstract class Literal extends Expression {} \ No newline at end of file diff --git a/jael/lib/src/ast/identifier.dart b/jael/lib/src/ast/identifier.dart new file mode 100644 index 00000000..fdfcbc36 --- /dev/null +++ b/jael/lib/src/ast/identifier.dart @@ -0,0 +1,23 @@ +import 'package:source_span/source_span.dart'; +import 'package:symbol_table/symbol_table.dart'; +import 'expression.dart'; +import 'token.dart'; + +class Identifier extends Expression { + final Token id; + + Identifier(this.id); + + @override + compute(SymbolTable scope) { + var symbol = scope.resolve(name); + if (symbol == null) + throw new ArgumentError('The name "$name" does not exist in this scope.'); + return scope.resolve(name).value; + } + + String get name => id.span.text; + + @override + FileSpan get span => id.span; +} diff --git a/jael/lib/src/ast/interpolation.dart b/jael/lib/src/ast/interpolation.dart new file mode 100644 index 00000000..2dacb790 --- /dev/null +++ b/jael/lib/src/ast/interpolation.dart @@ -0,0 +1,18 @@ +import 'package:source_span/source_span.dart'; +import 'element.dart'; +import 'expression.dart'; +import 'token.dart'; + +class Interpolation extends ElementChild { + final Token doubleCurlyL, doubleCurlyR; + final Expression expression; + + Interpolation(this.doubleCurlyL, this.expression, this.doubleCurlyR); + + bool get isRaw => doubleCurlyL.span.text.endsWith('-'); + + @override + FileSpan get span { + return doubleCurlyL.span.expand(expression.span).expand(doubleCurlyR.span); + } +} diff --git a/jael/lib/src/ast/map.dart b/jael/lib/src/ast/map.dart new file mode 100644 index 00000000..8f457f3b --- /dev/null +++ b/jael/lib/src/ast/map.dart @@ -0,0 +1,53 @@ +import 'package:source_span/source_span.dart'; +import 'ast_node.dart'; +import 'expression.dart'; +import 'identifier.dart'; +import 'token.dart'; + +class MapLiteral extends Literal { + final Token lCurly, rCurly; + final List pairs; + + MapLiteral(this.lCurly, this.pairs, this.rCurly); + + @override + compute(scope) { + return pairs.fold({}, (out, p) { + var key, value; + + if (p.colon == null) { + if (p.key is! Identifier) { + key = value = p.key.compute(scope); + } else { + key = (p.key as Identifier).name; + value = p.key.compute(scope); + } + } else { + key = p.key.compute(scope); + value = p.value.compute(scope); + } + + return out..[key] = value; + }); + } + + @override + FileSpan get span { + return pairs + .fold(lCurly.span, (out, p) => out.expand(p.span)) + .expand(rCurly.span); + } +} + +class KeyValuePair extends AstNode { + final Expression key, value; + final Token colon; + + KeyValuePair(this.key, this.colon, this.value); + + @override + FileSpan get span { + if (colon == null) return key.span; + return colon.span.expand(colon.span).expand(value.span); + } +} diff --git a/jael/lib/src/ast/member.dart b/jael/lib/src/ast/member.dart new file mode 100644 index 00000000..4423fe3e --- /dev/null +++ b/jael/lib/src/ast/member.dart @@ -0,0 +1,23 @@ +import 'dart:mirrors'; +import 'package:source_span/source_span.dart'; +import 'package:symbol_table/symbol_table.dart'; +import 'expression.dart'; +import 'identifier.dart'; +import 'token.dart'; + +class MemberExpression extends Expression { + final Expression expression; + final Token dot; + final Identifier name; + + MemberExpression(this.expression, this.dot, this.name); + + @override + compute(SymbolTable scope) { + var target = expression.compute(scope); + return reflect(target).getField(new Symbol(name.name)).reflectee; + } + + @override + FileSpan get span => expression.span.expand(dot.span).expand(name.span); +} diff --git a/jael/lib/src/ast/new.dart b/jael/lib/src/ast/new.dart new file mode 100644 index 00000000..6b801f17 --- /dev/null +++ b/jael/lib/src/ast/new.dart @@ -0,0 +1,31 @@ +import 'dart:mirrors'; +import 'package:source_span/source_span.dart'; +import 'call.dart'; +import 'expression.dart'; +import 'member.dart'; +import 'token.dart'; + +class NewExpression extends Expression { + final Token $new; + final Call call; + + NewExpression(this.$new, this.call); + + @override + FileSpan get span => $new.span.expand(call.span); + + @override + compute(scope) { + var targetType = call.target.compute(scope); + var positional = call.computePositional(scope); + var named = call.computeNamed(scope); + var name = ''; + + if (call.target is MemberExpression) + name = (call.target as MemberExpression).name.name; + + return reflectClass(targetType) + .newInstance(new Symbol(name), positional, named) + .reflectee; + } +} diff --git a/jael/lib/src/ast/number.dart b/jael/lib/src/ast/number.dart new file mode 100644 index 00000000..3ef3eab5 --- /dev/null +++ b/jael/lib/src/ast/number.dart @@ -0,0 +1,47 @@ +import 'dart:math' as math; +import 'package:source_span/source_span.dart'; +import 'expression.dart'; +import 'token.dart'; + +class NumberLiteral extends Literal { + final Token number; + num _value; + + NumberLiteral(this.number); + + @override + FileSpan get span => number.span; + + static num parse(String value) { + var e = value.indexOf('E'); + e != -1 ? e : e = value.indexOf('e'); + + if (e == -1) return num.parse(value); + + var plainNumber = num.parse(value.substring(0, e)); + var exp = value.substring(e + 1); + return plainNumber * math.pow(10, num.parse(exp)); + } + + @override + compute(scope) { + return _value ??= parse(number.span.text); + } +} + +class HexLiteral extends Literal { + final Token hex; + num _value; + + HexLiteral(this.hex); + + @override + FileSpan get span => hex.span; + + static num parse(String value) => int.parse(value.substring(2), radix: 16); + + @override + compute(scope) { + return _value ??= parse(hex.span.text); + } +} diff --git a/jael/lib/src/ast/string.dart b/jael/lib/src/ast/string.dart new file mode 100644 index 00000000..2586c77e --- /dev/null +++ b/jael/lib/src/ast/string.dart @@ -0,0 +1,74 @@ +import 'package:charcode/charcode.dart'; +import 'package:source_span/source_span.dart'; +import 'package:symbol_table/symbol_table.dart'; +import '../ast/ast.dart'; +import 'expression.dart'; +import 'token.dart'; + +class StringLiteral extends Literal { + final Token string; + final String value; + + StringLiteral(this.string, this.value); + + static String parseValue(Token string) { + var text = string.span.text.substring(1, string.span.text.length - 1); + var codeUnits = text.codeUnits; + var buf = new StringBuffer(); + + for (int i = 0; i < codeUnits.length; i++) { + var ch = codeUnits[i]; + + if (ch == $backslash) { + if (i < codeUnits.length - 5 && codeUnits[i + 1] == $u) { + var c1 = codeUnits[i += 2], + c2 = codeUnits[++i], + c3 = codeUnits[++i], + c4 = codeUnits[++i]; + var hexString = new String.fromCharCodes([c1, c2, c3, c4]); + var hexNumber = int.parse(hexString, radix: 16); + buf.write(new String.fromCharCode(hexNumber)); + continue; + } + + if (i < codeUnits.length - 1) { + var next = codeUnits[++i]; + + switch (next) { + case $b: + buf.write('\b'); + break; + case $f: + buf.write('\f'); + break; + case $n: + buf.writeCharCode($lf); + break; + case $r: + buf.writeCharCode($cr); + break; + case $t: + buf.writeCharCode($tab); + break; + default: + buf.writeCharCode(next); + } + } else + throw new JaelError(JaelErrorSeverity.error, + 'Unexpected "\\" in string literal.', string.span); + } else { + buf.writeCharCode(ch); + } + } + + return buf.toString(); + } + + @override + compute(SymbolTable scope) { + return value; + } + + @override + FileSpan get span => string.span; +} diff --git a/jael/lib/src/ast/token.dart b/jael/lib/src/ast/token.dart new file mode 100644 index 00000000..2e2dfd81 --- /dev/null +++ b/jael/lib/src/ast/token.dart @@ -0,0 +1,57 @@ +import 'package:source_span/source_span.dart'; + +class Token { + final TokenType type; + final FileSpan span; + + Token(this.type, this.span); + + @override + String toString() { + return '${span.start.toolString}: "${span.text}" => $type'; + } +} + +enum TokenType { + /* + * HTML + */ + doctype, + htmlComment, + lt, + gt, + slash, + equals, + id, + text, + + // Keywords + $new, + + /* + * Expression + */ + lBracket, + rBracket, + doubleCurlyL, + doubleCurlyR, + lCurly, + rCurly, + lParen, + rParen, + asterisk, + colon, + comma, + dot, + percent, + plus, + minus, + elvis, + lte, + gte, + equ, + nequ, + number, + hex, + string, +} \ No newline at end of file diff --git a/jael/lib/src/renderer.dart b/jael/lib/src/renderer.dart new file mode 100644 index 00000000..65b45e1b --- /dev/null +++ b/jael/lib/src/renderer.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; +import 'package:code_buffer/code_buffer.dart'; +import 'package:symbol_table/symbol_table.dart'; +import 'ast/ast.dart'; +import 'text/parser.dart'; +import 'text/scanner.dart'; + +/// Parses a Jael document. +Document parseDocument(String text, + {sourceUrl, void onError(JaelError error)}) { + var scanner = scan(text, sourceUrl: sourceUrl); + + if (scanner.errors.isNotEmpty && onError != null) + scanner.errors.forEach(onError); + else if (scanner.errors.isNotEmpty) throw scanner.errors.first; + + var parser = new Parser(scanner); + var doc = parser.parseDocument(); + + if (parser.errors.isNotEmpty && onError != null) + parser.errors.forEach(onError); + else if (parser.errors.isNotEmpty) throw parser.errors.first; + + return doc; +} + +class Renderer { + const Renderer(); + + void render(Document document, CodeBuffer buffer, SymbolTable scope) { + if (document.doctype != null) buffer.writeln(document.doctype.span.text); + renderElement( + document.root, buffer, scope, document.doctype?.public == null); + } + + void renderElement( + Element element, CodeBuffer buffer, SymbolTable scope, bool html5) { + if (element.attributes.any((a) => a.name.name == 'for-each')) { + renderForeach(element, buffer, scope, html5); + return; + } else if (element.attributes.any((a) => a.name.name == 'if')) { + renderIf(element, buffer, scope, html5); + return; + } + + buffer..write('<')..write(element.tagName.name); + + for (var attribute in element.attributes) { + var value = attribute.value?.compute(scope); + + if (value == false || value == null) continue; + + buffer.write(' ${attribute.name.name}'); + + if (value == true) + continue; + else + buffer.write('="'); + + String msg; + + if (value is Iterable) { + msg = value.join(' '); + } else if (value is Map) { + msg = value.keys.fold(new StringBuffer(), (buf, k) { + var v = value[k]; + if (v == null) return buf; + return buf..write('$k=$v;'); + }).toString(); + } else { + msg = value.toString(); + } + + buffer.write(HTML_ESCAPE.convert(msg)); + buffer.write('"'); + } + + if (element is SelfClosingElement) { + if (html5) + buffer.writeln('>'); + else + buffer.writeln('/>'); + } else { + buffer.writeln('>'); + buffer.indent(); + + for (int i = 0; i < element.children.length; i++) { + var child = element.children.elementAt(i); + renderElementChild( + child, buffer, scope, html5, i, element.children.length); + } + + buffer.writeln(); + buffer.outdent(); + buffer.writeln(''); + } + } + + void renderForeach( + Element element, CodeBuffer buffer, SymbolTable scope, bool html5) { + var attribute = + element.attributes.singleWhere((a) => a.name.name == 'for-each'); + if (attribute.value == null) return; + + var asAttribute = element.attributes + .firstWhere((a) => a.name.name == 'as', orElse: () => null); + var alias = asAttribute?.value?.compute(scope) ?? 'item'; + var otherAttributes = element.attributes + .where((a) => a.name.name != 'for-each' && a.name.name != 'as'); + Element strippedElement; + + if (element is SelfClosingElement) + strippedElement = new SelfClosingElement(element.lt, element.tagName, + otherAttributes, element.slash, element.gt); + else if (element is RegularElement) + strippedElement = new RegularElement( + element.lt, + element.tagName, + otherAttributes, + element.gt, + element.children, + element.lt2, + element.slash, + element.tagName2, + element.gt2); + + for (var item in attribute.value.compute(scope)) { + var childScope = scope.createChild(values: {alias: item}); + renderElement(strippedElement, buffer, childScope, html5); + } + } + + void renderIf( + Element element, CodeBuffer buffer, SymbolTable scope, bool html5) { + var attribute = element.attributes.singleWhere((a) => a.name.name == 'if'); + + if (!attribute.value.compute(scope)) return; + + var otherAttributes = element.attributes.where((a) => a.name.name != 'if'); + Element strippedElement; + + if (element is SelfClosingElement) + strippedElement = new SelfClosingElement(element.lt, element.tagName, + otherAttributes, element.slash, element.gt); + else if (element is RegularElement) + strippedElement = new RegularElement( + element.lt, + element.tagName, + otherAttributes, + element.gt, + element.children, + element.lt2, + element.slash, + element.tagName2, + element.gt2); + + renderElement(strippedElement, buffer, scope, html5); + } + + void renderElementChild(ElementChild child, CodeBuffer buffer, + SymbolTable scope, bool html5, int index, int total) { + if (child is Text) { + if (index == 0) + buffer.write(child.span.text.trimLeft()); + else if (index == total - 1) + buffer.write(child.span.text.trimRight()); + else + buffer.write(child.span.text); + } else if (child is Interpolation) { + var value = child.expression.compute(scope); + + if (value != null) { + if (child.isRaw) + buffer.write(value); + else + buffer.write(HTML_ESCAPE.convert(value.toString())); + } + } else if (child is Element) { + buffer.writeln(); + renderElement(child, buffer, scope, html5); + } + } +} diff --git a/jael/lib/src/text/parselet/infix.dart b/jael/lib/src/text/parselet/infix.dart new file mode 100644 index 00000000..6c80afd1 --- /dev/null +++ b/jael/lib/src/text/parselet/infix.dart @@ -0,0 +1,127 @@ +part of jael.src.text.parselet; + +const Map infixParselets = const { + TokenType.lParen: const CallParselet(), + TokenType.dot: const MemberParselet(), + TokenType.lBracket: const IndexerParselet(), + TokenType.asterisk: const BinaryParselet(14), + TokenType.slash: const BinaryParselet(14), + TokenType.percent: const BinaryParselet(14), + TokenType.plus: const BinaryParselet(13), + TokenType.minus: const BinaryParselet(13), + TokenType.lt: const BinaryParselet(11), + TokenType.lte: const BinaryParselet(11), + TokenType.gt: const BinaryParselet(11), + TokenType.gte: const BinaryParselet(11), + TokenType.equ: const BinaryParselet(10), + TokenType.nequ: const BinaryParselet(10), + TokenType.equals: const BinaryParselet(3), +}; + +class BinaryParselet implements InfixParselet { + final int precedence; + + const BinaryParselet(this.precedence); + + @override + Expression parse(Parser parser, Expression left, Token token) { + var right = parser.parseExpression(precedence); + + if (right == null) { + if (token.type != TokenType.gt) + parser.errors.add(new JaelError( + JaelErrorSeverity.error, + 'Missing expression after operator "${token.span.text}", following expression ${left.span.text}.', + token.span)); + return null; + } + + return new BinaryExpression(left, token, right); + } +} + +class CallParselet implements InfixParselet { + const CallParselet(); + + @override + int get precedence => 19; + + @override + Expression parse(Parser parser, Expression left, Token token) { + List arguments = []; + List namedArguments = []; + Expression argument = parser.parseExpression(0); + + while (argument != null) { + arguments.add(argument); + if (!parser.next(TokenType.comma)) break; + parser.skipExtraneous(TokenType.comma); + argument = parser.parseExpression(0); + } + + NamedArgument namedArgument = parser.parseNamedArgument(); + + while (namedArgument != null) { + namedArguments.add(namedArgument); + if (!parser.next(TokenType.comma)) break; + parser.skipExtraneous(TokenType.comma); + namedArgument = parser.parseNamedArgument(); + } + + if (!parser.next(TokenType.rParen)) { + var lastSpan = arguments.isEmpty ? null : arguments.last.span; + lastSpan ??= token.span; + parser.errors.add(new JaelError(JaelErrorSeverity.error, + 'Missing ")" after argument list.', lastSpan)); + return null; + } + + return new Call(left, token, parser.current, arguments, namedArguments); + } +} + +class IndexerParselet implements InfixParselet { + const IndexerParselet(); + + @override + int get precedence => 19; + + @override + Expression parse(Parser parser, Expression left, Token token) { + var indexer = parser.parseExpression(0); + + if (indexer == null) { + parser.errors.add(new JaelError( + JaelErrorSeverity.error, 'Missing expression after "[".', left.span)); + return null; + } + + if (!parser.next(TokenType.rBracket)) { + parser.errors.add( + new JaelError(JaelErrorSeverity.error, 'Missing "]".', indexer.span)); + return null; + } + + return new IndexerExpression(left, token, indexer, parser.current); + } +} + +class MemberParselet implements InfixParselet { + const MemberParselet(); + + @override + int get precedence => 19; + + @override + Expression parse(Parser parser, Expression left, Token token) { + var name = parser.parseIdentifier(); + + if (name == null) { + parser.errors.add(new JaelError(JaelErrorSeverity.error, + 'Expected the name of a property following "."', token.span)); + return null; + } + + return new MemberExpression(left, token, name); + } +} diff --git a/jael/lib/src/text/parselet/parselet.dart b/jael/lib/src/text/parselet/parselet.dart new file mode 100644 index 00000000..e4681ca1 --- /dev/null +++ b/jael/lib/src/text/parselet/parselet.dart @@ -0,0 +1,14 @@ +library jael.src.text.parselet; +import '../../ast/ast.dart'; +import '../parser.dart'; +part 'infix.dart'; +part 'prefix.dart'; + +abstract class PrefixParselet { + Expression parse(Parser parser, Token token); +} + +abstract class InfixParselet { + int get precedence; + Expression parse(Parser parser, Expression left, Token token); +} \ No newline at end of file diff --git a/jael/lib/src/text/parselet/prefix.dart b/jael/lib/src/text/parselet/prefix.dart new file mode 100644 index 00000000..3c17d2e9 --- /dev/null +++ b/jael/lib/src/text/parselet/prefix.dart @@ -0,0 +1,142 @@ +part of jael.src.text.parselet; + +const Map prefixParselets = const { + TokenType.$new: const NewParselet(), + TokenType.number: const NumberParselet(), + TokenType.hex: const HexParselet(), + TokenType.string: const StringParselet(), + TokenType.lCurly: const MapParselet(), + TokenType.lBracket: const ArrayParselet(), + TokenType.id: const IdentifierParselet(), + TokenType.lParen: const ParenthesisParselet(), +}; + +class NewParselet implements PrefixParselet { + const NewParselet(); + + @override + Expression parse(Parser parser, Token token) { + var call = parser.parseExpression(0); + + if (call == null) { + parser.errors.add(new JaelError( + JaelErrorSeverity.error, + '"new" must precede a call expression. Nothing was found.', + call.span)); + return null; + } else if (call is! Call) { + parser.errors.add(new JaelError( + JaelErrorSeverity.error, + '"new" must precede a call expression, not a(n) ${call.runtimeType}.', + call.span)); + return null; + } else { + return new NewExpression(token, call); + } + } +} + +class NumberParselet implements PrefixParselet { + const NumberParselet(); + + @override + Expression parse(Parser parser, Token token) => new NumberLiteral(token); +} + +class HexParselet implements PrefixParselet { + const HexParselet(); + + @override + Expression parse(Parser parser, Token token) => new HexLiteral(token); +} + +class StringParselet implements PrefixParselet { + const StringParselet(); + + @override + Expression parse(Parser parser, Token token) => + new StringLiteral(token, StringLiteral.parseValue(token)); +} + +class ArrayParselet implements PrefixParselet { + const ArrayParselet(); + + @override + Expression parse(Parser parser, Token token) { + List items = []; + Expression item = parser.parseExpression(0); + + while (item != null) { + items.add(item); + if (!parser.next(TokenType.comma)) break; + parser.skipExtraneous(TokenType.comma); + item = parser.parseExpression(0); + } + + if (!parser.next(TokenType.rBracket)) { + var lastSpan = items.isEmpty ? null : items.last.span; + lastSpan ??= token.span; + parser.errors.add(new JaelError(JaelErrorSeverity.error, + 'Missing "]" to terminate array literal.', lastSpan)); + return null; + } + + return new Array(token, parser.current, items); + } +} + +class MapParselet implements PrefixParselet { + const MapParselet(); + + @override + Expression parse(Parser parser, Token token) { + var pairs = []; + var pair = parser.parseKeyValuePair(); + + while (pair != null) { + pairs.add(pair); + if (!parser.next(TokenType.comma)) break; + parser.skipExtraneous(TokenType.comma); + pair = parser.parseKeyValuePair(); + } + + if (!parser.next(TokenType.rCurly)) { + var lastSpan = pairs.isEmpty ? token.span : pairs.last.span; + parser.errors.add(new JaelError( + JaelErrorSeverity.error, 'Missing "}" in map literal.', lastSpan)); + return null; + } + + return new MapLiteral(token, pairs, parser.current); + } +} + +class IdentifierParselet implements PrefixParselet { + const IdentifierParselet(); + + @override + Expression parse(Parser parser, Token token) => new Identifier(token); +} + +class ParenthesisParselet implements PrefixParselet { + const ParenthesisParselet(); + + @override + Expression parse(Parser parser, Token token) { + var expression = parser.parseExpression(0); + + if (expression == null) { + parser.errors.add(new JaelError(JaelErrorSeverity.error, + 'Missing expression after "(".', token.span)); + return null; + } + + if (!parser.next(TokenType.rParen)) { + parser.errors.add(new JaelError( + JaelErrorSeverity.error, 'Missing ")".', expression.span)); + return null; + } + + return expression; + } +} diff --git a/jael/lib/src/text/parser.dart b/jael/lib/src/text/parser.dart new file mode 100644 index 00000000..a7ca4041 --- /dev/null +++ b/jael/lib/src/text/parser.dart @@ -0,0 +1,362 @@ +import '../ast/ast.dart'; +import 'parselet/parselet.dart'; +import 'scanner.dart'; + +class Parser { + final List errors = []; + final Scanner scanner; + + Token _current; + int _index = -1; + + Parser(this.scanner); + + Token get current => _current; + + int _nextPrecedence() { + var tok = peek(); + if (tok == null) return 0; + + var parser = infixParselets[tok.type]; + return parser?.precedence ?? 0; + } + + bool next(TokenType type) { + if (_index >= scanner.tokens.length - 1) return false; + var peek = scanner.tokens[_index + 1]; + + if (peek.type != type) return false; + + _current = peek; + _index++; + return true; + } + + Token peek() { + if (_index >= scanner.tokens.length - 1) return null; + return scanner.tokens[_index + 1]; + } + + Token maybe(TokenType type) => next(type) ? _current : null; + + void skipExtraneous(TokenType type) { + while (next(type)) { + // Skip... + } + } + + Document parseDocument() { + var doctype = parseDoctype(); + + if (doctype == null) { + var root = parseElement(); + if (root == null) return null; + return new Document(null, root); + } + + var root = parseElement(); + + if (root == null) { + errors.add(new JaelError(JaelErrorSeverity.error, + 'Missing root element after !DOCTYPE declaration.', doctype.span)); + return null; + } + + return new Document(doctype, root); + } + + StringLiteral implicitString() { + if (next(TokenType.string)) { + return prefixParselets[TokenType.string].parse(this, _current); + } else if (next(TokenType.text)) { + + } + + return null; + } + + Doctype parseDoctype() { + if (!next(TokenType.lt)) return null; + var lt = _current; + + if (!next(TokenType.doctype)) { + _index--; + return null; + } + var doctype = _current, html = parseIdentifier(); + if (html?.span?.text?.toLowerCase() != 'html') { + errors.add(new JaelError(JaelErrorSeverity.error, + 'Expected "html" in doctype declaration.', html?.span ?? doctype.span)); + return null; + } + + var public = parseIdentifier(); + if (public == null) { + if (!next(TokenType.gt)) { + errors.add(new JaelError(JaelErrorSeverity.error, + 'Expected ">" in doctype declaration.', html.span)); + return null; + } + + return new Doctype(lt, doctype, html, null, null, null, _current); + } + + if (public?.span?.text?.toLowerCase() != 'public') { + errors.add(new JaelError(JaelErrorSeverity.error, + 'Expected "public" in doctype declaration.', public?.span ?? html.span)); + return null; + } + + var stringParser = prefixParselets[TokenType.string]; + + if (!next(TokenType.string)) { + errors.add(new JaelError(JaelErrorSeverity.error, + 'Expected string in doctype declaration.', public.span)); + return null; + } + + var name = stringParser.parse(this, _current); + + if (!next(TokenType.string)) { + errors.add(new JaelError(JaelErrorSeverity.error, + 'Expected string in doctype declaration.', name.span)); + return null; + } + + var url = stringParser.parse(this, _current); + + if (!next(TokenType.gt)) { + errors.add(new JaelError(JaelErrorSeverity.error, + 'Expected ">" in doctype declaration.', url.span)); + return null; + } + + return new Doctype(lt, doctype, html, public, name, url, _current); + } + + ElementChild parseElementChild() => + parseHtmlComment() ?? + parseInterpolation() ?? + parseText() ?? + parseElement(); + + HtmlComment parseHtmlComment() => + next(TokenType.htmlComment) ? new HtmlComment(_current) : null; + + Text parseText() => next(TokenType.text) ? new Text(_current) : null; + + Interpolation parseInterpolation() { + if (!next(TokenType.doubleCurlyL)) return null; + var doubleCurlyL = _current; + + var expression = parseExpression(0); + + if (expression == null) { + errors.add(new JaelError(JaelErrorSeverity.error, + 'Missing expression in interpolation.', doubleCurlyL.span)); + return null; + } + + if (!next(TokenType.doubleCurlyR)) { + errors.add(new JaelError(JaelErrorSeverity.error, + 'Missing closing "}}" in interpolation.', expression.span)); + return null; + } + + return new Interpolation(doubleCurlyL, expression, _current); + } + + Element parseElement() { + if (!next(TokenType.lt)) return null; + var lt = _current; + + if (next(TokenType.slash)) { + // We entered a closing tag, don't keep reading... + _index -= 2; + return null; + } + + var tagName = parseIdentifier(); + + if (tagName == null) { + errors.add( + new JaelError(JaelErrorSeverity.error, 'Missing tag name.', lt.span)); + return null; + } + + List attributes = []; + var attribute = parseAttribute(); + + while (attribute != null) { + attributes.add(attribute); + attribute = parseAttribute(); + } + + if (next(TokenType.slash)) { + // Try for self-closing... + var slash = _current; + + if (!next(TokenType.gt)) { + errors.add(new JaelError(JaelErrorSeverity.error, + 'Missing ">" in self-closing "${tagName.name}" tag.', slash.span)); + return null; + } + + return new SelfClosingElement(lt, tagName, attributes, slash, _current); + } + + if (!next(TokenType.gt)) { + errors.add(new JaelError( + JaelErrorSeverity.error, + 'Missing ">" in "${tagName.name}" tag.', + attributes.isEmpty ? tagName.span : attributes.last.span)); + return null; + } + + var gt = _current; + + // Implicit self-closing + if (Element.selfClosing.contains(tagName.name)) { + return new SelfClosingElement(lt, tagName, attributes, null, gt); + } + + List children = []; + var child = parseElementChild(); + + while (child != null) { + if (child is! HtmlComment) children.add(child); + child = parseElementChild(); + } + + // Parse closing tag + if (!next(TokenType.lt)) { + errors.add(new JaelError( + JaelErrorSeverity.error, + 'Missing closing tag for "${tagName.name}" tag.', + children.isEmpty ? tagName.span : children.last.span)); + return null; + } + + var lt2 = _current; + + if (!next(TokenType.slash)) { + errors.add(new JaelError(JaelErrorSeverity.error, + 'Missing "/" in "${tagName.name}" closing tag.', lt2.span)); + return null; + } + + var slash = _current, tagName2 = parseIdentifier(); + + if (tagName2 == null) { + errors.add(new JaelError( + JaelErrorSeverity.error, + 'Missing "${tagName.name}" in "${tagName.name}" closing tag.', + slash.span)); + return null; + } + + if (tagName2.name != tagName.name) { + errors.add(new JaelError( + JaelErrorSeverity.error, + 'Mismatched closing tags. Expected "${tagName.span}"; got "${tagName2.name}" instead.', + lt2.span)); + return null; + } + + if (!next(TokenType.gt)) { + errors.add(new JaelError(JaelErrorSeverity.error, + 'Missing ">" in "${tagName.name}" closing tag.', tagName2.span)); + return null; + } + + return new RegularElement( + lt, tagName, attributes, gt, children, lt2, slash, tagName2, _current); + } + + Attribute parseAttribute() { + var name = parseIdentifier(); + if (name == null) return null; + + if (!next(TokenType.equals)) return new Attribute(name, null, null); + + var equals = _current; + var value = parseExpression(0); + + if (value == null) { + errors.add(new JaelError(JaelErrorSeverity.error, + 'Missing expression in attribute.', equals.span)); + return null; + } + + return new Attribute(name, equals, value); + } + + Expression parseExpression(int precedence) { + // Only consume a token if it could potentially be a prefix parselet + + for (var type in prefixParselets.keys) { + if (next(type)) { + var left = prefixParselets[type].parse(this, _current); + + while (precedence < _nextPrecedence()) { + _current = scanner.tokens[++_index]; + var infix = infixParselets[_current.type]; + var newLeft = infix.parse(this, left, _current); + + if (newLeft == null) { + if (_current.type == TokenType.gt) + _index--; + return left; + } + left = newLeft; + } + + return left; + } + } + + // Nothing was parsed; return null. + return null; + } + + Identifier parseIdentifier() => + next(TokenType.id) ? new Identifier(_current) : null; + + KeyValuePair parseKeyValuePair() { + var key = parseExpression(0); + if (key == null) return null; + + if (!next(TokenType.colon)) return new KeyValuePair(key, null, null); + + var colon = _current, value = parseExpression(0); + + if (value == null) { + errors.add(new JaelError(JaelErrorSeverity.error, + 'Missing expression in key-value pair.', colon.span)); + return null; + } + + return new KeyValuePair(key, colon, value); + } + + NamedArgument parseNamedArgument() { + var name = parseIdentifier(); + if (name == null) return null; + + if (!next(TokenType.colon)) { + errors.add(new JaelError(JaelErrorSeverity.error, + 'Missing ":" in named argument.', name.span)); + return null; + } + + var colon = _current, value = parseExpression(0); + + if (value == null) { + errors.add(new JaelError(JaelErrorSeverity.error, + 'Missing expression in named argument.', colon.span)); + return null; + } + + return new NamedArgument(name, colon, value); + } +} diff --git a/jael/lib/src/text/scanner.dart b/jael/lib/src/text/scanner.dart new file mode 100644 index 00000000..bc54ba2c --- /dev/null +++ b/jael/lib/src/text/scanner.dart @@ -0,0 +1,177 @@ +import 'package:string_scanner/string_scanner.dart'; +import '../ast/ast.dart'; + +final RegExp _whitespace = new RegExp(r'[ \n\r\t]+'); + +final RegExp _string1 = new RegExp( + r"'((\\(['\\/bfnrt]|(u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))|([^'\\]))*'"); +final RegExp _string2 = new RegExp( + r'"((\\(["\\/bfnrt]|(u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))|([^"\\]))*"'); + +Scanner scan(String text, {sourceUrl}) => new _Scanner(text, sourceUrl)..scan(); + +abstract class Scanner { + List get errors; + + List get tokens; +} + +final Map _htmlPatterns = { + '{{': TokenType.doubleCurlyL, + '{{-': TokenType.doubleCurlyL, + + // + new RegExp(r''): TokenType.htmlComment, + '!DOCTYPE': TokenType.doctype, + '!doctype': TokenType.doctype, + '<': TokenType.lt, + '>': TokenType.gt, + '/': TokenType.slash, + '=': TokenType.equals, + _string1: TokenType.string, + _string2: TokenType.string, + new RegExp(r'([A-Za-z][A-Za-z0-9]*-)*([A-Za-z][A-Za-z0-9]*)'): TokenType.id, +}; + +final Map _expressionPatterns = { + '}}': TokenType.doubleCurlyR, + + // Keywords + 'new': TokenType.$new, + + // Misc. + '*': TokenType.asterisk, + ':': TokenType.colon, + ',': TokenType.comma, + '.': TokenType.dot, + '=': TokenType.equals, + '-': TokenType.minus, + '%': TokenType.percent, + '+': TokenType.plus, + '[': TokenType.lBracket, + ']': TokenType.rBracket, + '{': TokenType.lCurly, + '}': TokenType.rCurly, + '(': TokenType.lParen, + ')': TokenType.rParen, + '/': TokenType.slash, + '<': TokenType.lt, + '<=': TokenType.lte, + '>': TokenType.gt, + '>=': TokenType.gte, + '==': TokenType.equ, + '!=': TokenType.nequ, + '=': TokenType.equals, + new RegExp(r'-?[0-9]+(\.[0-9]+)?([Ee][0-9]+)?'): TokenType.number, + new RegExp(r'0x[A-Fa-f0-9]+'): TokenType.hex, + _string1: TokenType.string, + _string2: TokenType.string, + new RegExp('[A-Za-z_\\\$][A-Za-z0-9_\\\$]*'): TokenType.id, +}; + +class _Scanner implements Scanner { + final List errors = []; + final List tokens = []; + + SpanScanner _scanner; + + _Scanner(String text, sourceUrl) { + _scanner = new SpanScanner(text, sourceUrl: sourceUrl); + } + + Token _scanFrom(Map patterns, + [LineScannerState textStart]) { + var potential = []; + + patterns.forEach((pattern, type) { + if (_scanner.matches(pattern)) + potential.add(new Token(type, _scanner.lastSpan)); + }); + + if (potential.isEmpty) return null; + + if (textStart != null) { + var span = _scanner.spanFrom(textStart); + tokens.add(new Token(TokenType.text, span)); + } + + potential.sort((a, b) => b.span.length.compareTo(a.span.length)); + + var token = potential.first; + tokens.add(token); + + _scanner.scan(token.span.text); + + return token; + } + + void scan() { + while (!_scanner.isDone) scanHtmlTokens(); + } + + void scanHtmlTokens() { + LineScannerState textStart; + + while (!_scanner.isDone) { + var state = _scanner.state; + + // Skip whitespace conditionally + if (textStart == null) { + _scanner.scan(_whitespace); + } + + var lastToken = _scanFrom(_htmlPatterns, textStart); + + if (lastToken?.type == TokenType.equals) { + textStart = null; + scanExpressionTokens(); + return; + } else if (lastToken?.type == TokenType.doubleCurlyL) { + textStart = null; + scanExpressionTokens(true); + return; + } else if (lastToken?.type == TokenType.id && + tokens.length >= 2 && + tokens[tokens.length - 2].type == TokenType.gt) { + // Fold in the ID into a text node... + tokens.removeLast(); + textStart = state; + } else if (lastToken?.type == TokenType.id && + tokens.length >= 2 && + tokens[tokens.length - 2].type == TokenType.text) { + // Append the ID into the old text node + tokens.removeLast(); + tokens.removeLast(); + + // Not sure how, but the following logic seems to occur + // automatically: + // + // var textToken = tokens.removeLast(); + // var newSpan = textToken.span.expand(lastToken.span); + // tokens.add(new Token(TokenType.text, newSpan)); + } else if (lastToken != null) { + textStart = null; + } else if (!_scanner.isDone ?? lastToken == null) { + textStart ??= state; + _scanner.readChar(); + } + } + + if (textStart != null) { + var span = _scanner.spanFrom(textStart); + tokens.add(new Token(TokenType.text, span)); + } + } + + void scanExpressionTokens([bool allowGt = false]) { + Token lastToken; + + do { + _scanner.scan(_whitespace); + lastToken = _scanFrom(_expressionPatterns); + } while (!_scanner.isDone && + lastToken != null && + lastToken.type != TokenType.doubleCurlyR && + (allowGt || lastToken.type != TokenType.gt)); + } +} diff --git a/jael/pubspec.yaml b/jael/pubspec.yaml new file mode 100644 index 00000000..4caf4f02 --- /dev/null +++ b/jael/pubspec.yaml @@ -0,0 +1,15 @@ +name: jael +version: 1.0.0-alpha +description: A simple server-side HTML templating engine for Dart. +author: Tobe O +homepage: +environment: + sdk: ">=1.19.0" +dependencies: + charcode: ^1.0.0 + code_buffer: ^1.0.0 + source_span: ^1.0.0 + string_scanner: ^1.0.0 + symbol_table: ^1.0.0 +dev_dependencies: + test: ^0.12.0 \ No newline at end of file diff --git a/jael/test/render/render_test.dart b/jael/test/render/render_test.dart new file mode 100644 index 00000000..a9f306cd --- /dev/null +++ b/jael/test/render/render_test.dart @@ -0,0 +1,175 @@ +import 'package:code_buffer/code_buffer.dart'; +import 'package:jael/jael.dart' as jael; +import 'package:symbol_table/symbol_table.dart'; +import 'package:test/test.dart'; + +main() { + test('attribute binding', () { + const template = ''' + + +

Hello

+ + + +'''; + + var buf = new CodeBuffer(); + var document = jael.parseDocument(template, sourceUrl: 'test.jl'); + var scope = new SymbolTable(values: { + 'profile': { + 'avatar': 'thosakwe.png', + } + }); + + const jael.Renderer().render(document, buf, scope); + print(buf); + + expect( + buf.toString(), + ''' + + +

+ Hello +

+ + + + ''' + .trim()); + }); + + test('interpolation', () { + const template = ''' + + + +

Pokémon

+ {{ pokemon.name }} - {{ pokemon.type }} + + + +'''; + + var buf = new CodeBuffer(); + var document = jael.parseDocument(template, sourceUrl: 'test.jl'); + var scope = new SymbolTable(values: { + 'pokemon': const _Pokemon('Darkrai', 'Dark'), + }); + + const jael.Renderer().render(document, buf, scope); + print(buf); + + expect( + buf.toString(), + ''' + + + +

+ Pokémon +

+ Darkrai - Dark + + + + ''' + .trim()); + }); + + test('for loop', () { + const template = ''' + + +

Pokémon

+
    +
  • {{ starter.name }} - {{ starter.type }}
  • +
+ + +'''; + + var buf = new CodeBuffer(); + var document = jael.parseDocument(template, sourceUrl: 'test.jl'); + var scope = new SymbolTable(values: { + 'starters': starters, + }); + + const jael.Renderer().render(document, buf, scope); + print(buf); + + expect( + buf.toString(), + ''' + + +

+ Pokémon +

+
    +
  • + Bulbasaur - Grass +
  • +
  • + Charmander - Fire +
  • +
  • + Squirtle - Water +
  • +
+ + + ''' + .trim()); + }); + + test('conditional', () { + const template = ''' + + +

Conditional

+ Empty + Not empty + + +'''; + + var buf = new CodeBuffer(); + var document = jael.parseDocument(template, sourceUrl: 'test.jl'); + var scope = new SymbolTable(values: { + 'starters': starters, + }); + + const jael.Renderer().render(document, buf, scope); + print(buf); + + expect( + buf.toString(), + ''' + + +

+ Conditional +

+ + Not empty + + + + ''' + .trim()); + }); +} + +const List<_Pokemon> starters = const [ + const _Pokemon('Bulbasaur', 'Grass'), + const _Pokemon('Charmander', 'Fire'), + const _Pokemon('Squirtle', 'Water'), +]; + +class _Pokemon { + final String name, type; + + const _Pokemon(this.name, this.type); +} diff --git a/jael/test/text/common.dart b/jael/test/text/common.dart new file mode 100644 index 00000000..7d7108f4 --- /dev/null +++ b/jael/test/text/common.dart @@ -0,0 +1,24 @@ +import 'package:matcher/matcher.dart'; +import 'package:jael/src/ast/token.dart'; + +Matcher isToken(TokenType type, [String text]) => new _IsToken(type, text); + +class _IsToken extends Matcher { + final TokenType type; + final String text; + + _IsToken(this.type, [this.text]); + + @override + Description describe(Description description) { + if (text == null) return description.add('has type $type'); + return description.add('has type $type and text "$text"'); + } + + @override + bool matches(item, Map matchState) { + return item is Token && + item.type == type && + (text == null || item.span.text == text); + } +} diff --git a/jael/test/text/scan_test.dart b/jael/test/text/scan_test.dart new file mode 100644 index 00000000..c9097d98 --- /dev/null +++ b/jael/test/text/scan_test.dart @@ -0,0 +1,62 @@ +import 'package:jael/src/ast/token.dart'; +import 'package:jael/src/text/scanner.dart'; +import 'package:test/test.dart'; +import 'common.dart'; + +main() { + test('plain html', () { + var tokens = scan('', sourceUrl: 'test.jl').tokens; + tokens.forEach(print); + + expect(tokens, hasLength(7)); + expect(tokens[0], isToken(TokenType.lt)); + expect(tokens[1], isToken(TokenType.id, 'img')); + expect(tokens[2], isToken(TokenType.id, 'src')); + expect(tokens[3], isToken(TokenType.equals)); + expect(tokens[4], isToken(TokenType.string, '"foo.png"')); + expect(tokens[5], isToken(TokenType.slash)); + expect(tokens[6], isToken(TokenType.gt)); + }); + + test('text node', () { + var tokens = scan('

Hello\nworld

', sourceUrl: 'test.jl').tokens; + tokens.forEach(print); + + expect(tokens, hasLength(8)); + expect(tokens[0], isToken(TokenType.lt)); + expect(tokens[1], isToken(TokenType.id, 'p')); + expect(tokens[2], isToken(TokenType.gt)); + expect(tokens[3], isToken(TokenType.text, 'Hello\nworld')); + expect(tokens[4], isToken(TokenType.lt)); + expect(tokens[5], isToken(TokenType.slash)); + expect(tokens[6], isToken(TokenType.id, 'p')); + expect(tokens[7], isToken(TokenType.gt)); + }); + + test('mixed', () { + var tokens = scan('
    three{{four > five.six}}
', sourceUrl: 'test.jl').tokens; + tokens.forEach(print); + + expect(tokens, hasLength(20)); + expect(tokens[0], isToken(TokenType.lt)); + expect(tokens[1], isToken(TokenType.id, 'ul')); + expect(tokens[2], isToken(TokenType.id, 'number')); + expect(tokens[3], isToken(TokenType.equals)); + expect(tokens[4], isToken(TokenType.number, '1')); + expect(tokens[5], isToken(TokenType.plus)); + expect(tokens[6], isToken(TokenType.number, '2')); + expect(tokens[7], isToken(TokenType.gt)); + expect(tokens[8], isToken(TokenType.text, 'three')); + expect(tokens[9], isToken(TokenType.doubleCurlyL)); + expect(tokens[10], isToken(TokenType.id, 'four')); + expect(tokens[11], isToken(TokenType.gt)); + expect(tokens[12], isToken(TokenType.id, 'five')); + expect(tokens[13], isToken(TokenType.dot)); + expect(tokens[14], isToken(TokenType.id, 'six')); + expect(tokens[15], isToken(TokenType.doubleCurlyR)); + expect(tokens[16], isToken(TokenType.lt)); + expect(tokens[17], isToken(TokenType.slash)); + expect(tokens[18], isToken(TokenType.id, 'ul')); + expect(tokens[19], isToken(TokenType.gt)); + }); +} diff --git a/jael_preprocessor/.gitignore b/jael_preprocessor/.gitignore new file mode 100644 index 00000000..4353a22c --- /dev/null +++ b/jael_preprocessor/.gitignore @@ -0,0 +1,14 @@ +# Created by .ignore support plugin (hsz.mobi) +### Dart template +# See https://www.dartlang.org/tools/private-files.html + +# Files and directories created by pub +.packages +.pub/ +build/ +# If you're building an application, you may want to check-in your pubspec.lock +pubspec.lock + +# Directory created by dartdoc +# If you don't generate documentation locally you can remove this line. +doc/api/ diff --git a/jael_preprocessor/analysis_options.yaml b/jael_preprocessor/analysis_options.yaml new file mode 100644 index 00000000..518eb901 --- /dev/null +++ b/jael_preprocessor/analysis_options.yaml @@ -0,0 +1,2 @@ +analyzer: + strong-mode: true \ No newline at end of file diff --git a/travis.sh b/travis.sh new file mode 100644 index 00000000..0b294d92 --- /dev/null +++ b/travis.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +cd jael && pub get && pub run test +cd ../angel_jael && pub get && pub run test