From 50585ebe65698e4edcbf2f53134da84e68f7b233 Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Mon, 18 Nov 2013 11:25:26 -0800 Subject: [PATCH] feat(compiler): allow specifying attribute mappings using annotations Closes #227 --- lib/core/directive.dart | 158 ++++++++++++++++++++++- lib/core/module.dart | 1 + lib/core/registry.dart | 10 ++ lib/tools/source_metadata_extractor.dart | 2 +- lib/tools/utils.dart | 17 --- lib/utils.dart | 16 +++ test/core_dom/directive_spec.dart | 118 +++++++++++++++++ 7 files changed, 301 insertions(+), 21 deletions(-) delete mode 100644 lib/tools/utils.dart create mode 100644 test/core_dom/directive_spec.dart diff --git a/lib/core/directive.dart b/lib/core/directive.dart index db6dd1ba3..c59791b8b 100644 --- a/lib/core/directive.dart +++ b/lib/core/directive.dart @@ -1,6 +1,6 @@ part of angular.core; -class NgAnnotation { +abstract class NgAnnotation { /** * CSS selector which will trigger this component/directive. * CSS Selectors are limited to a single element and can contain: @@ -159,6 +159,7 @@ class NgAnnotation { operator==(other) => other is NgAnnotation && this.selector == other.selector; + NgAnnotation cloneWithNewMap(newMap); } @@ -228,6 +229,21 @@ class NgComponent extends NgAnnotation { map: map, exportExpressions: exportExpressions, exportExpressionAttrs: exportExpressionAttrs); + + NgAnnotation cloneWithNewMap(newMap) => + new NgComponent( + template: this.template, + templateUrl: this.templateUrl, + cssUrl: this.cssUrl, + applyAuthorStyles: this.applyAuthorStyles, + resetStyleInheritance: this.resetStyleInheritance, + publishAs: this.publishAs, + map: newMap, + selector: this.selector, + visibility: this.visibility, + publishTypes: this.publishTypes, + exportExpressions: this.exportExpressions, + exportExpressionAttrs: this.exportExpressionAttrs); } RegExp _ATTR_NAME = new RegExp(r'\[([^\]]+)\]$'); @@ -263,6 +279,17 @@ class NgDirective extends NgAnnotation { publishTypes: publishTypes, publishAs: publishAs, map: map, exportExpressions: exportExpressions, exportExpressionAttrs: exportExpressionAttrs); + + NgAnnotation cloneWithNewMap(newMap) => + new NgDirective( + children: this.children, + publishAs: this.publishAs, + map: newMap, + selector: this.selector, + visibility: this.visibility, + publishTypes: this.publishTypes, + exportExpressions: this.exportExpressions, + exportExpressionAttrs: this.exportExpressionAttrs); } /** @@ -299,6 +326,78 @@ class NgController extends NgDirective { publishTypes: publishTypes, publishAs: publishAs, map: map, exportExpressions: exportExpressions, exportExpressionAttrs: exportExpressionAttrs); + + NgAnnotation cloneWithNewMap(newMap) => + new NgController( + children: this.children, + publishAs: this.publishAs, + map: newMap, + selector: this.selector, + visibility: this.visibility, + publishTypes: this.publishTypes, + exportExpressions: this.exportExpressions, + exportExpressionAttrs: this.exportExpressionAttrs); +} + +abstract class AttrFieldAnnotation { + final String attrName; + const AttrFieldAnnotation(this.attrName); + String get mappingSpec; +} + +/** + * When applied as an annotation on a directive field specifies that + * the field is to be mapped to DOM attribute with the provided [attrName]. + * The value of the attribute to be treated as a string, equivalent + * to `@` specification. + */ +class NgAttr extends AttrFieldAnnotation { + final mappingSpec = '@'; + const NgAttr(String attrName) : super(attrName); +} + +/** + * When applied as an annotation on a directive field specifies that + * the field is to be mapped to DOM attribute with the provided [attrName]. + * The value of the attribute to be treated as a one-way expession, equivalent + * to `=>` specification. + */ +class NgOneWay extends AttrFieldAnnotation { + final mappingSpec = '=>'; + const NgOneWay(String attrName) : super(attrName); +} + +/** + * When applied as an annotation on a directive field specifies that + * the field is to be mapped to DOM attribute with the provided [attrName]. + * The value of the attribute to be treated as a one time one-way expession, + * equivalent to `=>!` specification. + */ +class NgOneWayOneTime extends AttrFieldAnnotation { + final mappingSpec = '=>!'; + const NgOneWayOneTime(String attrName) : super(attrName); +} + +/** + * When applied as an annotation on a directive field specifies that + * the field is to be mapped to DOM attribute with the provided [attrName]. + * The value of the attribute to be treated as a two-way expession, + * equivalent to `<=>` specification. + */ +class NgTwoWay extends AttrFieldAnnotation { + final mappingSpec = '<=>'; + const NgTwoWay(String attrName) : super(attrName); +} + +/** + * When applied as an annotation on a directive field specifies that + * the field is to be mapped to DOM attribute with the provided [attrName]. + * The value of the attribute to be treated as a callback expession, + * equivalent to `&` specification. + */ +class NgCallback extends AttrFieldAnnotation { + final mappingSpec = '&'; + const NgCallback(String attrName) : super(attrName); } /** @@ -320,6 +419,59 @@ abstract class NgDetachAware { } class DirectiveMap extends AnnotationMap { - DirectiveMap(Injector injector, MetadataExtractor metadataExtractor) - : super(injector, metadataExtractor); + DirectiveMap(Injector injector, MetadataExtractor metadataExtractor, + FieldMetadataExtractor fieldMetadataExtractor) + : super(injector, metadataExtractor) { + Map directives = {}; + forEach((NgAnnotation annotation, Type type) { + var match; + var fieldMetadata = fieldMetadataExtractor(type); + if (fieldMetadata.isNotEmpty) { + var newMap = annotation.map == null ? {} : new Map.from(annotation.map); + fieldMetadata.forEach((String fieldName, AttrFieldAnnotation ann) { + var attrName = ann.attrName; + if (newMap.containsKey(attrName)) { + throw 'Mapping for attribute $attrName is already defined (while ' + 'processing annottation for field $fieldName of $type)'; + } + newMap[attrName] = '${ann.mappingSpec}$fieldName'; + }); + annotation = annotation.cloneWithNewMap(newMap); + } + directives[annotation] = type; + }); + _map.clear(); + _map.addAll(directives); + } +} + +class FieldMetadataExtractor { + List _fieldAnnotations = [reflectType(NgAttr), + reflectType(NgOneWay), reflectType(NgOneWayOneTime), + reflectType(NgTwoWay), reflectType(NgCallback)]; + + Map call(Type type) { + ClassMirror cm = reflectType(type); + Map fields = {}; + cm.declarations.forEach((Symbol name, DeclarationMirror decl) { + if (decl is VariableMirror || + (decl is MethodMirror && (decl.isGetter || decl.isSetter))) { + var fieldName = MirrorSystem.getName(name); + if (decl is MethodMirror && decl.isSetter) { + // Remove = from the end of the setter. + fieldName = fieldName.substring(0, fieldName.length - 1); + } + decl.metadata.forEach((InstanceMirror meta) { + if (_fieldAnnotations.contains(meta.type)) { + if (fields[fieldName] != null) { + throw 'Attribute annotation for $fieldName is defined more ' + 'than once in $type'; + } + fields[fieldName] = meta.reflectee as AttrFieldAnnotation; + } + }); + } + }); + return fields; + } } diff --git a/lib/core/module.dart b/lib/core/module.dart index e4797be31..4faed5588 100644 --- a/lib/core/module.dart +++ b/lib/core/module.dart @@ -28,6 +28,7 @@ class NgCoreModule extends Module { type(ScopeDigestTTL); type(MetadataExtractor); + type(FieldMetadataExtractor); type(Cache); type(DirectiveMap); type(ExceptionHandler); diff --git a/lib/core/registry.dart b/lib/core/registry.dart index ce3a4d802..396f9fef9 100644 --- a/lib/core/registry.dart +++ b/lib/core/registry.dart @@ -27,6 +27,16 @@ abstract class AnnotationMap { } forEach(fn(K, Type)) => _map.forEach(fn); + + List annotationsFor(Type type) { + var res = []; + forEach((ann, annType) { + if (annType == type) { + res.add(ann); + } + }); + return res; + } } class MetadataExtractor { diff --git a/lib/tools/source_metadata_extractor.dart b/lib/tools/source_metadata_extractor.dart index 965f54241..f39051548 100644 --- a/lib/tools/source_metadata_extractor.dart +++ b/lib/tools/source_metadata_extractor.dart @@ -3,7 +3,7 @@ library angular.source_metadata_extractor ; import 'package:analyzer/src/generated/ast.dart'; import 'source_crawler.dart'; -import 'utils.dart'; +import '../utils.dart'; import 'common.dart'; const String _COMPONENT = '-component'; diff --git a/lib/tools/utils.dart b/lib/tools/utils.dart deleted file mode 100644 index bc70d2319..000000000 --- a/lib/tools/utils.dart +++ /dev/null @@ -1,17 +0,0 @@ -library angular.tools.utils; - -camelcase(String s) { - var part = s.split('-').map((s) => s.toLowerCase()); - if (part.length <= 1) { - return part.join(); - } - return part.first + part.skip(1).map(capitalize).join(); -} - -capitalize(String s) => s.substring(0, 1).toUpperCase() + s.substring(1); - -var SNAKE_CASE_REGEXP = new RegExp("[A-Z]"); - -snakecase(String name, [separator = '-']) => - name.replaceAllMapped(SNAKE_CASE_REGEXP, (Match match) => - (match.start != 0 ? separator : '') + match.group(0).toLowerCase()); diff --git a/lib/utils.dart b/lib/utils.dart index c52708198..9f0324747 100644 --- a/lib/utils.dart +++ b/lib/utils.dart @@ -70,3 +70,19 @@ relaxFnArgs(Function fn) { }; } } + +camelcase(String s) { + var part = s.split('-').map((s) => s.toLowerCase()); + if (part.length <= 1) { + return part.join(); + } + return part.first + part.skip(1).map(capitalize).join(); +} + +capitalize(String s) => s.substring(0, 1).toUpperCase() + s.substring(1); + +var SNAKE_CASE_REGEXP = new RegExp("[A-Z]"); + +snakecase(String name, [separator = '-']) => + name.replaceAllMapped(SNAKE_CASE_REGEXP, (Match match) => + (match.start != 0 ? separator : '') + match.group(0).toLowerCase()); diff --git a/test/core_dom/directive_spec.dart b/test/core_dom/directive_spec.dart new file mode 100644 index 000000000..f065bbdcd --- /dev/null +++ b/test/core_dom/directive_spec.dart @@ -0,0 +1,118 @@ +library directive_spec; + +import '../_specs.dart'; + +main() => describe('DirectiveMap', () { + + beforeEach(module((Module module) { + module + ..type(AnnotatedIoComponent); + })); + + it('should extract attr map from annotated component', inject((DirectiveMap directives) { + var annotations = directives.annotationsFor(AnnotatedIoComponent); + expect(annotations.length).toEqual(1); + expect(annotations[0] is NgComponent).toBeTruthy(); + + NgComponent annotation = annotations[0]; + expect(annotation.map).toEqual({ + 'foo': '=>foo', + 'attr': '@attr', + 'expr': '<=>expr', + 'expr-one-way': '=>exprOneWay', + 'expr-one-way-one-shot': '=>!exprOneWayOneShot', + 'callback': '&callback', + 'expr-one-way2': '=>exprOneWay2', + 'expr-two-way': '<=>exprTwoWay' + }); + })); + + describe('exceptions', () { + it('should throw when annotation is for existing mapping', () { + var module = new Module() + ..type(DirectiveMap) + ..type(Bad1Component) + ..type(MetadataExtractor) + ..type(FieldMetadataExtractor); + + var injector = new DynamicInjector(modules: [module]); + expect(() { + injector.get(DirectiveMap); + }).toThrow('Mapping for attribute foo is already defined (while ' + 'processing annottation for field foo of Bad1Component)'); + }); + + it('should throw when annotated both getter and setter', () { + var module = new Module() + ..type(DirectiveMap) + ..type(Bad2Component) + ..type(MetadataExtractor) + ..type(FieldMetadataExtractor); + + var injector = new DynamicInjector(modules: [module]); + expect(() { + injector.get(DirectiveMap); + }).toThrow('Attribute annotation for foo is defined more than once ' + 'in Bad2Component'); + }); + }); +}); + +@NgComponent( + selector: 'annotated-io', + template: r'', + map: const { + 'foo': '=>foo' + } +) +class AnnotatedIoComponent { + AnnotatedIoComponent(Scope scope) { + scope.$root.ioComponent = this; + } + + @NgAttr('attr') + String attr; + + @NgTwoWay('expr') + String expr; + + @NgOneWay('expr-one-way') + String exprOneWay; + + @NgOneWayOneTime('expr-one-way-one-shot') + String exprOneWayOneShot; + + @NgCallback('callback') + Function callback; + + @NgOneWay('expr-one-way2') + set exprOneWay2(val) {} + + @NgTwoWay('expr-two-way') + get exprTwoWay => null; + set exprTwoWay(val) {} +} + +@NgComponent( + selector: 'bad1', + template: r'', + map: const { + 'foo': '=>foo' + } +) +class Bad1Component { + @NgOneWay('foo') + String foo; +} + +@NgComponent( + selector: 'bad2', + template: r'' +) +class Bad2Component { + @NgOneWay('foo') + get foo => null; + + @NgOneWay('foo') + set foo(val) {} +}