| 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 * Logic to validate that developers are correctly using Polymer constructs. | |
| 7 * This is mainly used to produce warnings for feedback in the editor. | |
| 8 */ | |
| 9 library polymer.src.linter; | |
| 10 | |
| 11 import 'dart:io'; | |
| 12 import 'dart:async'; | |
| 13 import 'dart:mirrors'; | |
| 14 import 'dart:convert' show JSON; | |
| 15 | |
| 16 import 'package:barback/barback.dart'; | |
| 17 import 'package:html5lib/dom.dart'; | |
| 18 import 'package:html5lib/dom_parsing.dart'; | |
| 19 | |
| 20 import 'transform/common.dart'; | |
| 21 | |
| 22 typedef String MessageFormatter(String kind, String message, Span span); | |
| 23 | |
| 24 /** | |
| 25 * A linter that checks for common Polymer errors and produces warnings to | |
| 26 * show on the editor or the command line. Leaves sources unchanged, but creates | |
| 27 * a new asset containing all the warnings. | |
| 28 */ | |
| 29 class Linter extends Transformer with PolymerTransformer { | |
| 30 final TransformOptions options; | |
| 31 | |
| 32 /** Only run on .html files. */ | |
| 33 final String allowedExtensions = '.html'; | |
| 34 | |
| 35 final MessageFormatter _formatter; | |
| 36 | |
| 37 Linter(this.options, [this._formatter]); | |
| 38 | |
| 39 Future apply(Transform transform) { | |
| 40 var wrapper = new _LoggerInterceptor(transform, _formatter); | |
| 41 var seen = new Set<AssetId>(); | |
| 42 var primary = transform.primaryInput; | |
| 43 var id = primary.id; | |
| 44 wrapper.addOutput(primary); // this phase is analysis only | |
| 45 seen.add(id); | |
| 46 return readPrimaryAsHtml(wrapper).then((document) { | |
| 47 return _collectElements(document, id, wrapper, seen).then((elements) { | |
| 48 new _LinterVisitor(wrapper, elements).visit(document); | |
| 49 var messagesId = id.addExtension('.messages'); | |
| 50 wrapper.addOutput(new Asset.fromString(messagesId, | |
| 51 wrapper._messages.join('\n'))); | |
| 52 }); | |
| 53 }); | |
| 54 } | |
| 55 | |
| 56 /** | |
| 57 * Collect into [elements] any data about each polymer-element defined in | |
| 58 * [document] or any of it's imports, unless they have already been [seen]. | |
| 59 * Elements are added in the order they appear, transitive imports are added | |
| 60 * first. | |
| 61 */ | |
| 62 Future<Map<String, _ElementSummary>> _collectElements( | |
| 63 Document document, AssetId sourceId, Transform transform, | |
| 64 Set<AssetId> seen, [Map<String, _ElementSummary> elements]) { | |
| 65 if (elements == null) elements = <String, _ElementSummary>{}; | |
| 66 var logger = transform.logger; | |
| 67 // Note: the import order is relevant, so we visit in that order. | |
| 68 return Future.forEach(_getImportedIds(document, sourceId, logger), (id) { | |
| 69 if (seen.contains(id)) return new Future.value(null); | |
| 70 seen.add(id); | |
| 71 return readAsHtml(id, transform) | |
| 72 .then((doc) => _collectElements(doc, id, transform, seen, elements)); | |
| 73 }).then((_) { | |
| 74 _addElements(document, logger, elements); | |
| 75 return elements; | |
| 76 }); | |
| 77 } | |
| 78 | |
| 79 List<AssetId> _getImportedIds( | |
| 80 Document document, AssetId sourceId, TranformLogger logger) { | |
| 81 var importIds = []; | |
| 82 for (var tag in document.queryAll('link')) { | |
| 83 if (tag.attributes['rel'] != 'import') continue; | |
| 84 var href = tag.attributes['href']; | |
| 85 var id = resolve(sourceId, href, logger, tag.sourceSpan); | |
| 86 if (id == null) continue; | |
| 87 importIds.add(id); | |
| 88 } | |
| 89 return importIds; | |
| 90 } | |
| 91 | |
| 92 void _addElements(Document document, TransformLogger logger, | |
| 93 Map<String, _ElementSummary> elements) { | |
| 94 for (var tag in document.queryAll('polymer-element')) { | |
| 95 var name = tag.attributes['name']; | |
| 96 if (name == null) continue; | |
| 97 var extendsTag = tag.attributes['extends']; | |
| 98 var span = tag.sourceSpan; | |
| 99 var existing = elements[name]; | |
| 100 if (existing != null) { | |
| 101 | |
| 102 // Report warning only once. | |
| 103 if (existing.hasConflict) continue; | |
| 104 existing.hasConflict = true; | |
| 105 logger.warning('duplicate definition for custom tag "$name".', | |
| 106 existing.span); | |
| 107 logger.warning('duplicate definition for custom tag "$name" ' | |
| 108 ' (second definition).', span); | |
| 109 continue; | |
| 110 } | |
| 111 | |
| 112 elements[name] = new _ElementSummary(name, extendsTag, tag.sourceSpan); | |
| 113 } | |
| 114 } | |
| 115 } | |
| 116 | |
| 117 /** A proxy of [Transform] that returns a different logger. */ | |
| 118 // TODO(sigmund): get rid of this when barback supports a better way to log | |
| 119 // messages without printing them. | |
| 120 class _LoggerInterceptor implements Transform, TransformLogger { | |
| 121 final Transform _original; | |
| 122 final List<String> _messages = []; | |
| 123 final MessageFormatter _formatter; | |
| 124 | |
| 125 _LoggerInterceptor(this._original, MessageFormatter formatter) | |
| 126 : _formatter = formatter == null ? consoleFormatter : formatter; | |
| 127 | |
| 128 TransformLogger get logger => this; | |
| 129 | |
| 130 noSuchMethod(Invocation m) => reflect(_original).delegate(m); | |
| 131 | |
| 132 // form TransformLogger: | |
| 133 void warning(String message, [Span span]) => _write('warning', message, span); | |
| 134 | |
| 135 void error(String message, [Span span]) => _write('error', message, span); | |
| 136 | |
| 137 void _write(String kind, String message, Span span) { | |
| 138 _messages.add(_formatter(kind, message, span)); | |
| 139 } | |
| 140 } | |
| 141 | |
| 142 /** | |
| 143 * Formatter that generates messages using a format that can be parsed | |
| 144 * by tools, such as the Dart Editor, for reporting error messages. | |
| 145 */ | |
| 146 String jsonFormatter(String kind, String message, Span span) { | |
| 147 return JSON.encode((span == null) | |
| 148 ? [{'method': 'warning', 'params': {'message': message}}] | |
| 149 : [{'method': kind, | |
| 150 'params': { | |
| 151 'file': span.sourceUrl, | |
| 152 'message': message, | |
| 153 'line': span.start.line + 1, | |
| 154 'charStart': span.start.offset, | |
| 155 'charEnd': span.end.offset, | |
| 156 }}]); | |
| 157 } | |
| 158 | |
| 159 /** | |
| 160 * Formatter that generates messages that are easy to read on the console (used | |
| 161 * by default). | |
| 162 */ | |
| 163 String consoleFormatter(String kind, String message, Span span) { | |
| 164 var useColors = stdioType(stdout) == StdioType.TERMINAL; | |
| 165 var levelColor = (kind == 'error') ? _RED_COLOR : _MAGENTA_COLOR; | |
| 166 var output = new StringBuffer(); | |
| 167 if (useColors) output.write(levelColor); | |
| 168 output..write(kind)..write(' '); | |
| 169 if (useColors) output.write(_NO_COLOR); | |
| 170 if (span == null) { | |
| 171 output.write(message); | |
| 172 } else { | |
| 173 output.write(span.getLocationMessage(message, | |
| 174 useColors: useColors, | |
| 175 color: levelColor)); | |
| 176 } | |
| 177 return output.toString(); | |
| 178 } | |
| 179 | |
| 180 /** | |
| 181 * Information needed about other polymer-element tags in order to validate | |
| 182 * how they are used and extended. | |
| 183 */ | |
| 184 class _ElementSummary { | |
| 185 final String tagName; | |
| 186 final String extendsTag; | |
| 187 final Span span; | |
| 188 | |
| 189 _ElementSummary extendsType; | |
| 190 bool hasConflict = false; | |
| 191 | |
| 192 String get baseExtendsTag => extendsType == null | |
| 193 ? extendsTag : extendsType.baseExtendsTag; | |
| 194 | |
| 195 _ElementSummary(this.tagName, this.extendsTag, this.span); | |
| 196 | |
| 197 String toString() => "($tagName <: $extendsTag)"; | |
| 198 } | |
| 199 | |
| 200 class _LinterVisitor extends TreeVisitor { | |
| 201 TransformLogger _logger; | |
| 202 bool _inPolymerElement = false; | |
| 203 Map<String, _ElementSummary> _elements; | |
| 204 | |
| 205 _LinterVisitor(this._logger, this._elements) { | |
| 206 // We normalize the map, so each element has a direct reference to any | |
| 207 // element it extends from. | |
| 208 for (var tag in _elements.values) { | |
| 209 var extendsTag = tag.extendsTag; | |
| 210 if (extendsTag == null) continue; | |
| 211 tag.extendsType = _elements[extendsTag]; | |
| 212 } | |
| 213 } | |
| 214 | |
| 215 void visitElement(Element node) { | |
| 216 switch (node.tagName) { | |
| 217 case 'link': _validateLinkElement(node); break; | |
| 218 case 'element': _validateElementElement(node); break; | |
| 219 case 'polymer-element': _validatePolymerElement(node); break; | |
| 220 case 'script': _validateScriptElement(node); break; | |
| 221 default: | |
| 222 _validateNormalElement(node); | |
| 223 super.visitElement(node); | |
| 224 break; | |
| 225 } | |
| 226 } | |
| 227 | |
| 228 /** Produce warnings for invalid link-rel tags. */ | |
| 229 void _validateLinkElement(Element node) { | |
| 230 var rel = node.attributes['rel']; | |
| 231 if (rel != 'import' && rel != 'stylesheet') return; | |
| 232 | |
| 233 var href = node.attributes['href']; | |
| 234 if (href != null && href != '') return; | |
| 235 | |
| 236 // TODO(sigmund): warn also if href can't be resolved. | |
| 237 _logger.warning('link rel="$rel" missing href.', node.sourceSpan); | |
| 238 } | |
| 239 | |
| 240 /** Produce warnings if using `<element>` instead of `<polymer-element>`. */ | |
| 241 void _validateElementElement(Element node) { | |
| 242 _logger.warning('<element> elements are not supported, use' | |
| 243 ' <polymer-element> instead', node.sourceSpan); | |
| 244 } | |
| 245 | |
| 246 /** | |
| 247 * Produce warnings if using `<polymer-element>` in the wrong place or if the | |
| 248 * definition is not complete. | |
| 249 */ | |
| 250 void _validatePolymerElement(Element node) { | |
| 251 if (_inPolymerElement) { | |
| 252 _logger.error('Nested polymer element definitions are not allowed.', | |
| 253 node.sourceSpan); | |
| 254 return; | |
| 255 } | |
| 256 | |
| 257 var tagName = node.attributes['name']; | |
| 258 var extendsTag = node.attributes['extends']; | |
| 259 | |
| 260 if (tagName == null) { | |
| 261 _logger.error('Missing tag name of the custom element. Please include an ' | |
| 262 'attribute like \'name="your-tag-name"\'.', | |
| 263 node.sourceSpan); | |
| 264 } else if (!_isCustomTag(tagName)) { | |
| 265 _logger.error('Invalid name "$tagName". Custom element names must have ' | |
| 266 'at least one dash and can\'t be any of the following names: ' | |
| 267 '${_invalidTagNames.keys.join(", ")}.', | |
| 268 node.sourceSpan); | |
| 269 } | |
| 270 | |
| 271 if (_elements[extendsTag] == null && _isCustomTag(extendsTag)) { | |
| 272 _logger.warning('custom element with name "$extendsTag" not found.', | |
| 273 node.sourceSpan); | |
| 274 } | |
| 275 | |
| 276 var oldValue = _inPolymerElement; | |
| 277 _inPolymerElement = true; | |
| 278 super.visitElement(node); | |
| 279 _inPolymerElement = oldValue; | |
| 280 } | |
| 281 | |
| 282 /** | |
| 283 * Produces warnings for malformed script tags. In html5 leaving off type= is | |
| 284 * fine, but it defaults to text/javascript. Because this might be a common | |
| 285 * error, we warn about it when src file ends in .dart, but the type is | |
| 286 * incorrect, or when users write code in an inline script tag of a custom | |
| 287 * element. | |
| 288 * | |
| 289 * The hope is that these cases shouldn't break existing valid code, but that | |
| 290 * they'll help Polymer authors avoid having their Dart code accidentally | |
| 291 * interpreted as JavaScript by the browser. | |
| 292 */ | |
| 293 void _validateScriptElement(Element node) { | |
| 294 var scriptType = node.attributes['type']; | |
| 295 var src = node.attributes['src']; | |
| 296 | |
| 297 if (scriptType == null) { | |
| 298 if (src == null && _inPolymerElement) { | |
| 299 // TODO(sigmund): revisit this check once we start interop with polymer | |
| 300 // elements written in JS. Maybe we need to inspect the contents of the | |
| 301 // script to find whether there is an import or something that indicates | |
| 302 // that the code is indeed using Dart. | |
| 303 _logger.warning('script tag in polymer element with no type will ' | |
| 304 'be treated as JavaScript. Did you forget type="application/dart"?', | |
| 305 node.sourceSpan); | |
| 306 } | |
| 307 if (src != null && src.endsWith('.dart')) { | |
| 308 _logger.warning('script tag with .dart source file but no type will ' | |
| 309 'be treated as JavaScript. Did you forget type="application/dart"?', | |
| 310 node.sourceSpan); | |
| 311 } | |
| 312 return; | |
| 313 } | |
| 314 | |
| 315 if (scriptType != 'application/dart') return; | |
| 316 | |
| 317 if (src != null) { | |
| 318 if (!src.endsWith('.dart')) { | |
| 319 _logger.warning('"application/dart" scripts should ' | |
| 320 'use the .dart file extension.', | |
| 321 node.sourceSpan); | |
| 322 } | |
| 323 | |
| 324 if (node.innerHtml.trim() != '') { | |
| 325 _logger.warning('script tag has "src" attribute and also has script ' | |
| 326 'text.', node.sourceSpan); | |
| 327 } | |
| 328 } | |
| 329 } | |
| 330 | |
| 331 /** | |
| 332 * Produces warnings for misuses of on-foo event handlers, and for instanting | |
| 333 * custom tags incorrectly. | |
| 334 */ | |
| 335 void _validateNormalElement(Element node) { | |
| 336 // Event handlers only allowed inside polymer-elements | |
| 337 node.attributes.forEach((name, value) { | |
| 338 if (name.startsWith('on')) { | |
| 339 _validateEventHandler(node, name, value); | |
| 340 } | |
| 341 }); | |
| 342 | |
| 343 // Validate uses of custom-tags | |
| 344 var nodeTag = node.tagName; | |
| 345 var hasIsAttribute; | |
| 346 var customTagName; | |
| 347 if (_isCustomTag(nodeTag)) { | |
| 348 // <fancy-button> | |
| 349 customTagName = nodeTag; | |
| 350 hasIsAttribute = false; | |
| 351 } else { | |
| 352 // <button is="fancy-button"> | |
| 353 customTagName = node.attributes['is']; | |
| 354 hasIsAttribute = true; | |
| 355 } | |
| 356 | |
| 357 if (customTagName == null || customTagName == 'polymer-element') return; | |
| 358 | |
| 359 var info = _elements[customTagName]; | |
| 360 if (info == null) { | |
| 361 _logger.warning('definition for custom element with tag name ' | |
| 362 '"$customTagName" not found.', node.sourceSpan); | |
| 363 return; | |
| 364 } | |
| 365 | |
| 366 var baseTag = info.baseExtendsTag; | |
| 367 if (baseTag != null && !hasIsAttribute) { | |
| 368 _logger.warning( | |
| 369 'custom element "$customTagName" extends from "$baseTag", but ' | |
| 370 'this tag will not include the default properties of "$baseTag". ' | |
| 371 'To fix this, either write this tag as <$baseTag ' | |
| 372 'is="$customTagName"> or remove the "extends" attribute from ' | |
| 373 'the custom element declaration.', node.sourceSpan); | |
| 374 return; | |
| 375 } | |
| 376 | |
| 377 if (hasIsAttribute && baseTag == null) { | |
| 378 _logger.warning( | |
| 379 'custom element "$customTagName" doesn\'t declare any type ' | |
| 380 'extensions. To fix this, either rewrite this tag as ' | |
| 381 '<$customTagName> or add \'extends="$nodeTag"\' to ' | |
| 382 'the custom element declaration.', node.sourceSpan); | |
| 383 return; | |
| 384 } | |
| 385 | |
| 386 if (hasIsAttribute && baseTag != nodeTag) { | |
| 387 _logger.warning( | |
| 388 'custom element "$customTagName" extends from "$baseTag". ' | |
| 389 'Did you mean to write <$baseTag is="$customTagName">?', | |
| 390 node.sourceSpan); | |
| 391 } | |
| 392 } | |
| 393 | |
| 394 /** Validate event handlers are used correctly. */ | |
| 395 void _validateEventHandler(Element node, String name, String value) { | |
| 396 if (!name.startsWith('on-')) { | |
| 397 _logger.warning('Event handler "$name" will be interpreted as an inline' | |
| 398 ' JavaScript event handler. Use the form ' | |
| 399 'on-event-name="handlerName" if you want a Dart handler ' | |
| 400 'that will automatically update the UI based on model changes.', | |
| 401 node.sourceSpan); | |
| 402 return; | |
| 403 } | |
| 404 | |
| 405 if (!_inPolymerElement) { | |
| 406 _logger.warning('Inline event handlers are only supported inside ' | |
| 407 'declarations of <polymer-element>.', node.sourceSpan); | |
| 408 } | |
| 409 | |
| 410 if (value.contains('.') || value.contains('(')) { | |
| 411 _logger.warning('Invalid event handler body "$value". Declare a method ' | |
| 412 'in your custom element "void handlerName(event, detail, target)" ' | |
| 413 'and use the form $name="handlerName".', | |
| 414 node.sourceSpan); | |
| 415 } | |
| 416 } | |
| 417 } | |
| 418 | |
| 419 | |
| 420 // These names have meaning in SVG or MathML, so they aren't allowed as custom | |
| 421 // tags. | |
| 422 var _invalidTagNames = const { | |
| 423 'annotation-xml': '', | |
| 424 'color-profile': '', | |
| 425 'font-face': '', | |
| 426 'font-face-src': '', | |
| 427 'font-face-uri': '', | |
| 428 'font-face-format': '', | |
| 429 'font-face-name': '', | |
| 430 'missing-glyph': '', | |
| 431 }; | |
| 432 | |
| 433 /** | |
| 434 * Returns true if this is a valid custom element name. See: | |
| 435 * <https://dvcs.w3.org/hg/webcomponents/raw-file/tip/spec/custom/index.html#dfn
-custom-element-name> | |
| 436 */ | |
| 437 bool _isCustomTag(String name) { | |
| 438 if (name == null || !name.contains('-')) return false; | |
| 439 return !_invalidTagNames.containsKey(name); | |
| 440 } | |
| 441 | |
| 442 final String _RED_COLOR = '\u001b[31m'; | |
| 443 final String _MAGENTA_COLOR = '\u001b[35m'; | |
| 444 final String _NO_COLOR = '\u001b[0m'; | |
| OLD | NEW |