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 |