OLD | NEW |
(Empty) | |
| 1 // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file |
| 2 // for details. All rights reserved. Use of this source code is governed by a |
| 3 // BSD-style license that can be found in the LICENSE file. |
| 4 |
| 5 /** |
| 6 * Code transform for @observable. The core transformation is relatively |
| 7 * straightforward, and essentially like an editor refactoring. |
| 8 */ |
| 9 library observe.transform; |
| 10 |
| 11 import 'dart:async'; |
| 12 import 'package:path/path.dart' as path; |
| 13 import 'package:analyzer_experimental/src/generated/ast.dart'; |
| 14 import 'package:analyzer_experimental/src/generated/error.dart'; |
| 15 import 'package:analyzer_experimental/src/generated/parser.dart'; |
| 16 import 'package:analyzer_experimental/src/generated/scanner.dart'; |
| 17 import 'package:barback/barback.dart'; |
| 18 import 'package:source_maps/refactor.dart'; |
| 19 import 'package:source_maps/span.dart' show SourceFile; |
| 20 |
| 21 /** |
| 22 * A [Transformer] that replaces observables based on dirty-checking with an |
| 23 * implementation based on change notifications. |
| 24 * |
| 25 * The transformation adds hooks for field setters and notifies the observation |
| 26 * system of the change. |
| 27 */ |
| 28 class ObservableTransformer extends Transformer { |
| 29 |
| 30 Future<bool> isPrimary(Asset input) { |
| 31 if (input.id.extension != '.dart') return new Future.value(false); |
| 32 // Note: technically we should parse the file to find accurately the |
| 33 // observable annotation, but that seems expensive. It would require almost |
| 34 // as much work as applying the transform. We rather have some false |
| 35 // positives here, and then generate no outputs when we apply this |
| 36 // transform. |
| 37 return input.readAsString().then((c) => c.contains("@observable")); |
| 38 } |
| 39 |
| 40 Future apply(Transform transform) { |
| 41 return transform.primaryInput |
| 42 .then((input) => input.readAsString().then((content) { |
| 43 var id = transform.primaryId; |
| 44 // TODO(sigmund): improve how we compute this url |
| 45 var url = id.path.startsWith('lib/') |
| 46 ? 'package:${id.package}/${id.path.substring(4)}' : id.path; |
| 47 var sourceFile = new SourceFile.text(url, content); |
| 48 var transaction = _transformCompilationUnit( |
| 49 content, sourceFile, transform.logger); |
| 50 if (!transaction.hasEdits) { |
| 51 transform.addOutput(input); |
| 52 return; |
| 53 } |
| 54 var printer = transaction.commit(); |
| 55 // TODO(sigmund): emit source maps when barback supports it (see |
| 56 // dartbug.com/12340) |
| 57 printer.build(url); |
| 58 transform.addOutput(new Asset.fromString(id, printer.text)); |
| 59 })); |
| 60 } |
| 61 } |
| 62 |
| 63 TextEditTransaction _transformCompilationUnit( |
| 64 String inputCode, SourceFile sourceFile, TransformLogger logger) { |
| 65 var unit = _parseCompilationUnit(inputCode); |
| 66 return transformObservables(unit, sourceFile, inputCode, logger); |
| 67 } |
| 68 |
| 69 // TODO(sigmund): make this private. This is currently public so it can be used |
| 70 // by the polymer.dart package which is not yet entirely migrated to use |
| 71 // pub-serve and pub-deploy. |
| 72 TextEditTransaction transformObservables(CompilationUnit unit, |
| 73 SourceFile sourceFile, String content, TransformLogger logger) { |
| 74 var code = new TextEditTransaction(content, sourceFile); |
| 75 for (var directive in unit.directives) { |
| 76 if (directive is LibraryDirective && _hasObservable(directive)) { |
| 77 logger.warning('@observable on a library no longer has any effect. ' |
| 78 'It should be placed on individual fields.', |
| 79 _getSpan(sourceFile, directive)); |
| 80 break; |
| 81 } |
| 82 } |
| 83 |
| 84 for (var declaration in unit.declarations) { |
| 85 if (declaration is ClassDeclaration) { |
| 86 _transformClass(declaration, code, sourceFile, logger); |
| 87 } else if (declaration is TopLevelVariableDeclaration) { |
| 88 if (_hasObservable(declaration)) { |
| 89 logger.warning('Top-level fields can no longer be observable. ' |
| 90 'Observable fields should be put in an observable objects.', |
| 91 _getSpan(sourceFile, declaration)); |
| 92 } |
| 93 } |
| 94 } |
| 95 return code; |
| 96 } |
| 97 |
| 98 /** Parse [code] using analyzer_experimental. */ |
| 99 CompilationUnit _parseCompilationUnit(String code) { |
| 100 var errorListener = new _ErrorCollector(); |
| 101 var scanner = new StringScanner(null, code, errorListener); |
| 102 var token = scanner.tokenize(); |
| 103 var parser = new Parser(null, errorListener); |
| 104 return parser.parseCompilationUnit(token); |
| 105 } |
| 106 |
| 107 class _ErrorCollector extends AnalysisErrorListener { |
| 108 final errors = <AnalysisError>[]; |
| 109 onError(error) => errors.add(error); |
| 110 } |
| 111 |
| 112 _getSpan(SourceFile file, ASTNode node) => file.span(node.offset, node.end); |
| 113 |
| 114 /** True if the node has the `@observable` annotation. */ |
| 115 bool _hasObservable(AnnotatedNode node) => _hasAnnotation(node, 'observable'); |
| 116 |
| 117 bool _hasAnnotation(AnnotatedNode node, String name) { |
| 118 // TODO(jmesserly): this isn't correct if the annotation has been imported |
| 119 // with a prefix, or cases like that. We should technically be resolving, but |
| 120 // that is expensive. |
| 121 return node.metadata.any((m) => m.name.name == name && |
| 122 m.constructorName == null && m.arguments == null); |
| 123 } |
| 124 |
| 125 void _transformClass(ClassDeclaration cls, TextEditTransaction code, |
| 126 SourceFile file, TransformLogger logger) { |
| 127 |
| 128 if (_hasObservable(cls)) { |
| 129 logger.warning('@observable on a class no longer has any effect. ' |
| 130 'It should be placed on individual fields.', |
| 131 _getSpan(file, cls)); |
| 132 } |
| 133 |
| 134 // We'd like to track whether observable was declared explicitly, otherwise |
| 135 // report a warning later below. Because we don't have type analysis (only |
| 136 // syntactic understanding of the code), we only report warnings that are |
| 137 // known to be true. |
| 138 var declaresObservable = false; |
| 139 if (cls.extendsClause != null) { |
| 140 var id = _getSimpleIdentifier(cls.extendsClause.superclass.name); |
| 141 if (id.name == 'ObservableBase') { |
| 142 code.edit(id.offset, id.end, 'ChangeNotifierBase'); |
| 143 declaresObservable = true; |
| 144 } else if (id.name == 'ChangeNotifierBase') { |
| 145 declaresObservable = true; |
| 146 } else if (id.name != 'PolymerElement' && id.name != 'CustomElement' |
| 147 && id.name != 'Object') { |
| 148 // TODO(sigmund): this is conservative, consider using type-resolution to |
| 149 // improve this check. |
| 150 declaresObservable = true; |
| 151 } |
| 152 } |
| 153 |
| 154 if (cls.withClause != null) { |
| 155 for (var type in cls.withClause.mixinTypes) { |
| 156 var id = _getSimpleIdentifier(type.name); |
| 157 if (id.name == 'ObservableMixin') { |
| 158 code.edit(id.offset, id.end, 'ChangeNotifierMixin'); |
| 159 declaresObservable = true; |
| 160 break; |
| 161 } else if (id.name == 'ChangeNotifierMixin') { |
| 162 declaresObservable = true; |
| 163 break; |
| 164 } else { |
| 165 // TODO(sigmund): this is conservative, consider using type-resolution |
| 166 // to improve this check. |
| 167 declaresObservable = true; |
| 168 } |
| 169 } |
| 170 } |
| 171 |
| 172 if (!declaresObservable && cls.implementsClause != null) { |
| 173 // TODO(sigmund): consider adding type-resolution to give a more precise |
| 174 // answer. |
| 175 declaresObservable = true; |
| 176 } |
| 177 |
| 178 // Track fields that were transformed. |
| 179 var instanceFields = new Set<String>(); |
| 180 var getters = new List<String>(); |
| 181 var setters = new List<String>(); |
| 182 |
| 183 for (var member in cls.members) { |
| 184 if (member is FieldDeclaration) { |
| 185 bool isStatic = _hasKeyword(member.keyword, Keyword.STATIC); |
| 186 if (isStatic) { |
| 187 if (_hasObservable(member)){ |
| 188 logger.warning('Static fields can no longer be observable. ' |
| 189 'Observable fields should be put in an observable objects.', |
| 190 _getSpan(file, member)); |
| 191 } |
| 192 continue; |
| 193 } |
| 194 if (_hasObservable(member)) { |
| 195 if (!declaresObservable) { |
| 196 logger.warning('Observable fields should be put in an observable' |
| 197 ' objects. Please declare that this class extends from ' |
| 198 'ObservableBase, includes ObservableMixin, or implements ' |
| 199 'Observable.', |
| 200 _getSpan(file, member)); |
| 201 |
| 202 } |
| 203 _transformFields(member.fields, code, member.offset, member.end); |
| 204 |
| 205 var names = member.fields.variables.map((v) => v.name.name); |
| 206 |
| 207 getters.addAll(names); |
| 208 if (!_isReadOnly(member.fields)) { |
| 209 setters.addAll(names); |
| 210 instanceFields.addAll(names); |
| 211 } |
| 212 } |
| 213 } |
| 214 // TODO(jmesserly): this is a temporary workaround until we can remove |
| 215 // getValueWorkaround and setValueWorkaround. |
| 216 if (member is MethodDeclaration) { |
| 217 if (_hasKeyword(member.propertyKeyword, Keyword.GET)) { |
| 218 getters.add(member.name.name); |
| 219 } else if (_hasKeyword(member.propertyKeyword, Keyword.SET)) { |
| 220 setters.add(member.name.name); |
| 221 } |
| 222 } |
| 223 } |
| 224 |
| 225 // If nothing was @observable, bail. |
| 226 if (instanceFields.length == 0) return; |
| 227 |
| 228 // Fix initializers, because they aren't allowed to call the setter. |
| 229 for (var member in cls.members) { |
| 230 if (member is ConstructorDeclaration) { |
| 231 _fixConstructor(member, code, instanceFields); |
| 232 } |
| 233 } |
| 234 } |
| 235 |
| 236 SimpleIdentifier _getSimpleIdentifier(Identifier id) => |
| 237 id is PrefixedIdentifier ? (id as PrefixedIdentifier).identifier : id; |
| 238 |
| 239 |
| 240 bool _hasKeyword(Token token, Keyword keyword) => |
| 241 token is KeywordToken && (token as KeywordToken).keyword == keyword; |
| 242 |
| 243 String _getOriginalCode(TextEditTransaction code, ASTNode node) => |
| 244 code.original.substring(node.offset, node.end); |
| 245 |
| 246 void _fixConstructor(ConstructorDeclaration ctor, TextEditTransaction code, |
| 247 Set<String> changedFields) { |
| 248 |
| 249 // Fix normal initializers |
| 250 for (var initializer in ctor.initializers) { |
| 251 if (initializer is ConstructorFieldInitializer) { |
| 252 var field = initializer.fieldName; |
| 253 if (changedFields.contains(field.name)) { |
| 254 code.edit(field.offset, field.end, '__\$${field.name}'); |
| 255 } |
| 256 } |
| 257 } |
| 258 |
| 259 // Fix "this." initializer in parameter list. These are tricky: |
| 260 // we need to preserve the name and add an initializer. |
| 261 // Preserving the name is important for named args, and for dartdoc. |
| 262 // BEFORE: Foo(this.bar, this.baz) { ... } |
| 263 // AFTER: Foo(bar, baz) : __$bar = bar, __$baz = baz { ... } |
| 264 |
| 265 var thisInit = []; |
| 266 for (var param in ctor.parameters.parameters) { |
| 267 if (param is DefaultFormalParameter) { |
| 268 param = param.parameter; |
| 269 } |
| 270 if (param is FieldFormalParameter) { |
| 271 var name = param.identifier.name; |
| 272 if (changedFields.contains(name)) { |
| 273 thisInit.add(name); |
| 274 // Remove "this." but keep everything else. |
| 275 code.edit(param.thisToken.offset, param.period.end, ''); |
| 276 } |
| 277 } |
| 278 } |
| 279 |
| 280 if (thisInit.length == 0) return; |
| 281 |
| 282 // TODO(jmesserly): smarter formatting with indent, etc. |
| 283 var inserted = thisInit.map((i) => '__\$$i = $i').join(', '); |
| 284 |
| 285 int offset; |
| 286 if (ctor.separator != null) { |
| 287 offset = ctor.separator.end; |
| 288 inserted = ' $inserted,'; |
| 289 } else { |
| 290 offset = ctor.parameters.end; |
| 291 inserted = ' : $inserted'; |
| 292 } |
| 293 |
| 294 code.edit(offset, offset, inserted); |
| 295 } |
| 296 |
| 297 bool _isReadOnly(VariableDeclarationList fields) { |
| 298 return _hasKeyword(fields.keyword, Keyword.CONST) || |
| 299 _hasKeyword(fields.keyword, Keyword.FINAL); |
| 300 } |
| 301 |
| 302 void _transformFields(VariableDeclarationList fields, TextEditTransaction code, |
| 303 int begin, int end) { |
| 304 |
| 305 if (_isReadOnly(fields)) return; |
| 306 |
| 307 var indent = guessIndent(code.original, begin); |
| 308 var replace = new StringBuffer(); |
| 309 |
| 310 // Unfortunately "var" doesn't work in all positions where type annotations |
| 311 // are allowed, such as "var get name". So we use "dynamic" instead. |
| 312 var type = 'dynamic'; |
| 313 if (fields.type != null) { |
| 314 type = _getOriginalCode(code, fields.type); |
| 315 } |
| 316 |
| 317 for (var field in fields.variables) { |
| 318 var initializer = ''; |
| 319 if (field.initializer != null) { |
| 320 initializer = ' = ${_getOriginalCode(code, field.initializer)}'; |
| 321 } |
| 322 |
| 323 var name = field.name.name; |
| 324 |
| 325 // TODO(jmesserly): should we generate this one one line, so source maps |
| 326 // don't break? |
| 327 if (replace.length > 0) replace.write('\n\n$indent'); |
| 328 replace.write(''' |
| 329 $type __\$$name$initializer; |
| 330 $type get $name => __\$$name; |
| 331 set $name($type value) { |
| 332 __\$$name = notifyPropertyChange(const Symbol('$name'), __\$$name, value); |
| 333 } |
| 334 '''.replaceAll('\n', '\n$indent')); |
| 335 } |
| 336 |
| 337 code.edit(begin, end, '$replace'); |
| 338 } |
OLD | NEW |