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 /// Code transform for @observable. The core transformation is relatively |
| 6 /// straightforward, and essentially like an editor refactoring. |
| 7 library observe.transformer; |
| 8 |
| 9 import 'dart:async'; |
| 10 |
| 11 import 'package:analyzer/analyzer.dart'; |
| 12 import 'package:analyzer/src/generated/ast.dart'; |
| 13 import 'package:analyzer/src/generated/error.dart'; |
| 14 import 'package:analyzer/src/generated/parser.dart'; |
| 15 import 'package:analyzer/src/generated/scanner.dart'; |
| 16 import 'package:barback/barback.dart'; |
| 17 import 'package:code_transformers/messages/build_logger.dart'; |
| 18 import 'package:source_maps/refactor.dart'; |
| 19 import 'package:source_span/source_span.dart'; |
| 20 |
| 21 import 'src/messages.dart'; |
| 22 |
| 23 /// A [Transformer] that replaces observables based on dirty-checking with an |
| 24 /// implementation based on change notifications. |
| 25 /// |
| 26 /// The transformation adds hooks for field setters and notifies the observation |
| 27 /// system of the change. |
| 28 class ObservableTransformer extends Transformer { |
| 29 |
| 30 final bool releaseMode; |
| 31 final List<String> _files; |
| 32 ObservableTransformer([List<String> files, bool releaseMode]) |
| 33 : _files = files, releaseMode = releaseMode == true; |
| 34 ObservableTransformer.asPlugin(BarbackSettings settings) |
| 35 : _files = _readFiles(settings.configuration['files']), |
| 36 releaseMode = settings.mode == BarbackMode.RELEASE; |
| 37 |
| 38 static List<String> _readFiles(value) { |
| 39 if (value == null) return null; |
| 40 var files = []; |
| 41 bool error; |
| 42 if (value is List) { |
| 43 files = value; |
| 44 error = value.any((e) => e is! String); |
| 45 } else if (value is String) { |
| 46 files = [value]; |
| 47 error = false; |
| 48 } else { |
| 49 error = true; |
| 50 } |
| 51 if (error) print('Invalid value for "files" in the observe transformer.'); |
| 52 return files; |
| 53 } |
| 54 |
| 55 // TODO(nweiz): This should just take an AssetId when barback <0.13.0 support |
| 56 // is dropped. |
| 57 Future<bool> isPrimary(idOrAsset) { |
| 58 var id = idOrAsset is AssetId ? idOrAsset : idOrAsset.id; |
| 59 return new Future.value(id.extension == '.dart' && |
| 60 (_files == null || _files.contains(id.path))); |
| 61 } |
| 62 |
| 63 Future apply(Transform transform) { |
| 64 return transform.primaryInput.readAsString().then((content) { |
| 65 // Do a quick string check to determine if this is this file even |
| 66 // plausibly might need to be transformed. If not, we can avoid an |
| 67 // expensive parse. |
| 68 if (!observableMatcher.hasMatch(content)) return null; |
| 69 |
| 70 var id = transform.primaryInput.id; |
| 71 // TODO(sigmund): improve how we compute this url |
| 72 var url = id.path.startsWith('lib/') |
| 73 ? 'package:${id.package}/${id.path.substring(4)}' : id.path; |
| 74 var sourceFile = new SourceFile(content, url: url); |
| 75 var logger = new BuildLogger(transform, |
| 76 convertErrorsToWarnings: !releaseMode, |
| 77 detailsUri: 'http://goo.gl/5HPeuP'); |
| 78 var transaction = _transformCompilationUnit( |
| 79 content, sourceFile, logger); |
| 80 if (!transaction.hasEdits) { |
| 81 transform.addOutput(transform.primaryInput); |
| 82 } else { |
| 83 var printer = transaction.commit(); |
| 84 // TODO(sigmund): emit source maps when barback supports it (see |
| 85 // dartbug.com/12340) |
| 86 printer.build(url); |
| 87 transform.addOutput(new Asset.fromString(id, printer.text)); |
| 88 } |
| 89 return logger.writeOutput(); |
| 90 }); |
| 91 } |
| 92 } |
| 93 |
| 94 TextEditTransaction _transformCompilationUnit( |
| 95 String inputCode, SourceFile sourceFile, BuildLogger logger) { |
| 96 var unit = parseCompilationUnit(inputCode, suppressErrors: true); |
| 97 var code = new TextEditTransaction(inputCode, sourceFile); |
| 98 for (var directive in unit.directives) { |
| 99 if (directive is LibraryDirective && _hasObservable(directive)) { |
| 100 logger.warning(NO_OBSERVABLE_ON_LIBRARY, |
| 101 span: _getSpan(sourceFile, directive)); |
| 102 break; |
| 103 } |
| 104 } |
| 105 |
| 106 for (var declaration in unit.declarations) { |
| 107 if (declaration is ClassDeclaration) { |
| 108 _transformClass(declaration, code, sourceFile, logger); |
| 109 } else if (declaration is TopLevelVariableDeclaration) { |
| 110 if (_hasObservable(declaration)) { |
| 111 logger.warning(NO_OBSERVABLE_ON_TOP_LEVEL, |
| 112 span: _getSpan(sourceFile, declaration)); |
| 113 } |
| 114 } |
| 115 } |
| 116 return code; |
| 117 } |
| 118 |
| 119 _getSpan(SourceFile file, AstNode node) => file.span(node.offset, node.end); |
| 120 |
| 121 /// True if the node has the `@observable` or `@published` annotation. |
| 122 // TODO(jmesserly): it is not good to be hard coding Polymer support here. |
| 123 bool _hasObservable(AnnotatedNode node) => |
| 124 node.metadata.any(_isObservableAnnotation); |
| 125 |
| 126 // TODO(jmesserly): this isn't correct if the annotation has been imported |
| 127 // with a prefix, or cases like that. We should technically be resolving, but |
| 128 // that is expensive in analyzer, so it isn't feasible yet. |
| 129 bool _isObservableAnnotation(Annotation node) => |
| 130 _isAnnotationContant(node, 'observable') || |
| 131 _isAnnotationContant(node, 'published') || |
| 132 _isAnnotationType(node, 'ObservableProperty') || |
| 133 _isAnnotationType(node, 'PublishedProperty'); |
| 134 |
| 135 bool _isAnnotationContant(Annotation m, String name) => |
| 136 m.name.name == name && m.constructorName == null && m.arguments == null; |
| 137 |
| 138 bool _isAnnotationType(Annotation m, String name) => m.name.name == name; |
| 139 |
| 140 void _transformClass(ClassDeclaration cls, TextEditTransaction code, |
| 141 SourceFile file, BuildLogger logger) { |
| 142 |
| 143 if (_hasObservable(cls)) { |
| 144 logger.warning(NO_OBSERVABLE_ON_CLASS, span: _getSpan(file, cls)); |
| 145 } |
| 146 |
| 147 // We'd like to track whether observable was declared explicitly, otherwise |
| 148 // report a warning later below. Because we don't have type analysis (only |
| 149 // syntactic understanding of the code), we only report warnings that are |
| 150 // known to be true. |
| 151 var explicitObservable = false; |
| 152 var implicitObservable = false; |
| 153 if (cls.extendsClause != null) { |
| 154 var id = _getSimpleIdentifier(cls.extendsClause.superclass.name); |
| 155 if (id.name == 'Observable') { |
| 156 code.edit(id.offset, id.end, 'ChangeNotifier'); |
| 157 explicitObservable = true; |
| 158 } else if (id.name == 'ChangeNotifier') { |
| 159 explicitObservable = true; |
| 160 } else if (id.name != 'HtmlElement' && id.name != 'CustomElement' |
| 161 && id.name != 'Object') { |
| 162 // TODO(sigmund): this is conservative, consider using type-resolution to |
| 163 // improve this check. |
| 164 implicitObservable = true; |
| 165 } |
| 166 } |
| 167 |
| 168 if (cls.withClause != null) { |
| 169 for (var type in cls.withClause.mixinTypes) { |
| 170 var id = _getSimpleIdentifier(type.name); |
| 171 if (id.name == 'Observable') { |
| 172 code.edit(id.offset, id.end, 'ChangeNotifier'); |
| 173 explicitObservable = true; |
| 174 break; |
| 175 } else if (id.name == 'ChangeNotifier') { |
| 176 explicitObservable = true; |
| 177 break; |
| 178 } else { |
| 179 // TODO(sigmund): this is conservative, consider using type-resolution |
| 180 // to improve this check. |
| 181 implicitObservable = true; |
| 182 } |
| 183 } |
| 184 } |
| 185 |
| 186 if (cls.implementsClause != null) { |
| 187 // TODO(sigmund): consider adding type-resolution to give a more precise |
| 188 // answer. |
| 189 implicitObservable = true; |
| 190 } |
| 191 |
| 192 var declaresObservable = explicitObservable || implicitObservable; |
| 193 |
| 194 // Track fields that were transformed. |
| 195 var instanceFields = new Set<String>(); |
| 196 |
| 197 for (var member in cls.members) { |
| 198 if (member is FieldDeclaration) { |
| 199 if (member.isStatic) { |
| 200 if (_hasObservable(member)){ |
| 201 logger.warning(NO_OBSERVABLE_ON_STATIC_FIELD, |
| 202 span: _getSpan(file, member)); |
| 203 } |
| 204 continue; |
| 205 } |
| 206 if (_hasObservable(member)) { |
| 207 if (!declaresObservable) { |
| 208 logger.warning(REQUIRE_OBSERVABLE_INTERFACE, |
| 209 span: _getSpan(file, member)); |
| 210 } |
| 211 _transformFields(file, member, code, logger); |
| 212 |
| 213 var names = member.fields.variables.map((v) => v.name.name); |
| 214 |
| 215 if (!_isReadOnly(member.fields)) instanceFields.addAll(names); |
| 216 } |
| 217 } |
| 218 } |
| 219 |
| 220 // If nothing was @observable, bail. |
| 221 if (instanceFields.length == 0) return; |
| 222 |
| 223 if (!explicitObservable) _mixinObservable(cls, code); |
| 224 |
| 225 // Fix initializers, because they aren't allowed to call the setter. |
| 226 for (var member in cls.members) { |
| 227 if (member is ConstructorDeclaration) { |
| 228 _fixConstructor(member, code, instanceFields); |
| 229 } |
| 230 } |
| 231 } |
| 232 |
| 233 /// Adds "with ChangeNotifier" and associated implementation. |
| 234 void _mixinObservable(ClassDeclaration cls, TextEditTransaction code) { |
| 235 // Note: we need to be careful to put the with clause after extends, but |
| 236 // before implements clause. |
| 237 if (cls.withClause != null) { |
| 238 var pos = cls.withClause.end; |
| 239 code.edit(pos, pos, ', ChangeNotifier'); |
| 240 } else if (cls.extendsClause != null) { |
| 241 var pos = cls.extendsClause.end; |
| 242 code.edit(pos, pos, ' with ChangeNotifier '); |
| 243 } else { |
| 244 var params = cls.typeParameters; |
| 245 var pos = params != null ? params.end : cls.name.end; |
| 246 code.edit(pos, pos, ' extends ChangeNotifier '); |
| 247 } |
| 248 } |
| 249 |
| 250 SimpleIdentifier _getSimpleIdentifier(Identifier id) => |
| 251 id is PrefixedIdentifier ? id.identifier : id; |
| 252 |
| 253 |
| 254 bool _hasKeyword(Token token, Keyword keyword) => |
| 255 token is KeywordToken && token.keyword == keyword; |
| 256 |
| 257 String _getOriginalCode(TextEditTransaction code, AstNode node) => |
| 258 code.original.substring(node.offset, node.end); |
| 259 |
| 260 void _fixConstructor(ConstructorDeclaration ctor, TextEditTransaction code, |
| 261 Set<String> changedFields) { |
| 262 |
| 263 // Fix normal initializers |
| 264 for (var initializer in ctor.initializers) { |
| 265 if (initializer is ConstructorFieldInitializer) { |
| 266 var field = initializer.fieldName; |
| 267 if (changedFields.contains(field.name)) { |
| 268 code.edit(field.offset, field.end, '__\$${field.name}'); |
| 269 } |
| 270 } |
| 271 } |
| 272 |
| 273 // Fix "this." initializer in parameter list. These are tricky: |
| 274 // we need to preserve the name and add an initializer. |
| 275 // Preserving the name is important for named args, and for dartdoc. |
| 276 // BEFORE: Foo(this.bar, this.baz) { ... } |
| 277 // AFTER: Foo(bar, baz) : __$bar = bar, __$baz = baz { ... } |
| 278 |
| 279 var thisInit = []; |
| 280 for (var param in ctor.parameters.parameters) { |
| 281 if (param is DefaultFormalParameter) { |
| 282 param = param.parameter; |
| 283 } |
| 284 if (param is FieldFormalParameter) { |
| 285 var name = param.identifier.name; |
| 286 if (changedFields.contains(name)) { |
| 287 thisInit.add(name); |
| 288 // Remove "this." but keep everything else. |
| 289 code.edit(param.thisToken.offset, param.period.end, ''); |
| 290 } |
| 291 } |
| 292 } |
| 293 |
| 294 if (thisInit.length == 0) return; |
| 295 |
| 296 // TODO(jmesserly): smarter formatting with indent, etc. |
| 297 var inserted = thisInit.map((i) => '__\$$i = $i').join(', '); |
| 298 |
| 299 int offset; |
| 300 if (ctor.separator != null) { |
| 301 offset = ctor.separator.end; |
| 302 inserted = ' $inserted,'; |
| 303 } else { |
| 304 offset = ctor.parameters.end; |
| 305 inserted = ' : $inserted'; |
| 306 } |
| 307 |
| 308 code.edit(offset, offset, inserted); |
| 309 } |
| 310 |
| 311 bool _isReadOnly(VariableDeclarationList fields) { |
| 312 return _hasKeyword(fields.keyword, Keyword.CONST) || |
| 313 _hasKeyword(fields.keyword, Keyword.FINAL); |
| 314 } |
| 315 |
| 316 void _transformFields(SourceFile file, FieldDeclaration member, |
| 317 TextEditTransaction code, BuildLogger logger) { |
| 318 |
| 319 final fields = member.fields; |
| 320 if (_isReadOnly(fields)) return; |
| 321 |
| 322 // Private fields aren't supported: |
| 323 for (var field in fields.variables) { |
| 324 final name = field.name.name; |
| 325 if (Identifier.isPrivateName(name)) { |
| 326 logger.warning('Cannot make private field $name observable.', |
| 327 span: _getSpan(file, field)); |
| 328 return; |
| 329 } |
| 330 } |
| 331 |
| 332 // Unfortunately "var" doesn't work in all positions where type annotations |
| 333 // are allowed, such as "var get name". So we use "dynamic" instead. |
| 334 var type = 'dynamic'; |
| 335 if (fields.type != null) { |
| 336 type = _getOriginalCode(code, fields.type); |
| 337 } else if (_hasKeyword(fields.keyword, Keyword.VAR)) { |
| 338 // Replace 'var' with 'dynamic' |
| 339 code.edit(fields.keyword.offset, fields.keyword.end, type); |
| 340 } |
| 341 |
| 342 // Note: the replacements here are a bit subtle. It needs to support multiple |
| 343 // fields declared via the same @observable, as well as preserving newlines. |
| 344 // (Preserving newlines is important because it allows the generated code to |
| 345 // be debugged without needing a source map.) |
| 346 // |
| 347 // For example: |
| 348 // |
| 349 // @observable |
| 350 // @otherMetaData |
| 351 // Foo |
| 352 // foo = 1, bar = 2, |
| 353 // baz; |
| 354 // |
| 355 // Will be transformed into something like: |
| 356 // |
| 357 // @reflectable @observable |
| 358 // @OtherMetaData() |
| 359 // Foo |
| 360 // get foo => __foo; Foo __foo = 1; @reflectable set foo ...; ... |
| 361 // @observable @OtherMetaData() Foo get baz => __baz; Foo baz; ... |
| 362 // |
| 363 // Metadata is moved to the getter. |
| 364 |
| 365 String metadata = ''; |
| 366 if (fields.variables.length > 1) { |
| 367 metadata = member.metadata |
| 368 .map((m) => _getOriginalCode(code, m)) |
| 369 .join(' '); |
| 370 metadata = '@reflectable $metadata'; |
| 371 } |
| 372 |
| 373 for (int i = 0; i < fields.variables.length; i++) { |
| 374 final field = fields.variables[i]; |
| 375 final name = field.name.name; |
| 376 |
| 377 var beforeInit = 'get $name => __\$$name; $type __\$$name'; |
| 378 |
| 379 // The first field is expanded differently from subsequent fields, because |
| 380 // we can reuse the metadata and type annotation. |
| 381 if (i == 0) { |
| 382 final begin = member.metadata.first.offset; |
| 383 code.edit(begin, begin, '@reflectable '); |
| 384 } else { |
| 385 beforeInit = '$metadata $type $beforeInit'; |
| 386 } |
| 387 |
| 388 code.edit(field.name.offset, field.name.end, beforeInit); |
| 389 |
| 390 // Replace comma with semicolon |
| 391 final end = _findFieldSeperator(field.endToken.next); |
| 392 if (end.type == TokenType.COMMA) code.edit(end.offset, end.end, ';'); |
| 393 |
| 394 code.edit(end.end, end.end, ' @reflectable set $name($type value) { ' |
| 395 '__\$$name = notifyPropertyChange(#$name, __\$$name, value); }'); |
| 396 } |
| 397 } |
| 398 |
| 399 Token _findFieldSeperator(Token token) { |
| 400 while (token != null) { |
| 401 if (token.type == TokenType.COMMA || token.type == TokenType.SEMICOLON) { |
| 402 break; |
| 403 } |
| 404 token = token.next; |
| 405 } |
| 406 return token; |
| 407 } |
| 408 |
| 409 // TODO(sigmund): remove hard coded Polymer support (@published). The proper way |
| 410 // to do this would be to switch to use the analyzer to resolve whether |
| 411 // annotations are subtypes of ObservableProperty. |
| 412 final observableMatcher = |
| 413 new RegExp("@(published|observable|PublishedProperty|ObservableProperty)"); |
OLD | NEW |