Validate `[attr.foo.if]`: check type bool, and `[attr.foo]` exists. (#624)

As a bonus, make `[attr.foo.if]` click-thru navigable to `[attr.foo]`.

Did some cleanup of the way that these binding type names are cut off.
The diff is ugly but it's mostly just a simple rename. Previous naming
was not clear, and the change required to lop off the 'if' from the name
was not easy to clearly add in.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 76e277f..f4c8ffd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 - Typecheck the results and input of pipe expressions and the existence of a
   matching pipe. Optional arguments are not yet typechecked.
+- Add typechecking support for `[attr.foo.if]`, and ensure that a corresponding
+  `[attr.foo]` binding exists.
 
 ## 0.0.17+3
 
diff --git a/README.md b/README.md
index 8b35d1f..2b3ed1c 100644
--- a/README.md
+++ b/README.md
@@ -106,6 +106,7 @@
 `<input [value]="firstName">` | :white_check_mark: soundness of expression, type of expression, existence of `value` on element or directive | :white_check_mark: | :white_check_mark: | :x:
 `<input bind-value="firstName">` | :white_check_mark: | :skull: | :white_check_mark: | :x:
 `<div [attr.role]="myAriaRole">` | :last_quarter_moon: soundness of expression, but no other validation | :last_quarter_moon: complete inside binding but binding not suggested | :last_quarter_moon: no html specific navigation | :x:
+`<div [attr.role.if]="myAriaRole">` | :white_check_mark: | :last_quarter_moon: complete inside binding but binding not suggested | :white_check_mark: | :x:
 `<div [class.extra-sparkle]="isDelightful">` | :white_check_mark: validity of clasname, soundness of expression, type of expression must be bool | :last_quarter_moon: complete inside binding but binding not suggested | :last_quarter_moon: no css specific navigation | :x:
 `<div [style.width.px]="mySize">` | :waning_gibbous_moon: soundness of expression, css properties are generally checked but not against a dictionary, same for units, expression must type to `int` if units are present | :last_quarter_moon: complete inside binding but binding not suggested | :last_quarter_moon: no css specific navigation | :x:
 `<button (click)="readRainbow($event)">` | :white_check_mark: soundness of expression, type of `$event`, existence of output on component/element and DOM events which propagate can be tracked anywhere | :white_check_mark: | :last_quarter_moon: no navigation for `$event` | :x:
diff --git a/angular_analyzer_plugin/lib/ast.dart b/angular_analyzer_plugin/lib/ast.dart
index e983e91..ec682ef 100644
--- a/angular_analyzer_plugin/lib/ast.dart
+++ b/angular_analyzer_plugin/lib/ast.dart
@@ -272,7 +272,7 @@
   String toString() => '(${super.toString()}, [$bound, $expression])';
 }
 
-enum ExpressionBoundType { input, twoWay, attr, clazz, style }
+enum ExpressionBoundType { input, twoWay, attr, attrIf, clazz, style }
 
 /// An AngularAstNode which has directives, such as [ElementInfo] and
 /// [TemplateAttribute]. Contains an array of [DirectiveBinding]s because those
diff --git a/angular_analyzer_plugin/lib/errors.dart b/angular_analyzer_plugin/lib/errors.dart
index 5493cf4..c373786 100644
--- a/angular_analyzer_plugin/lib/errors.dart
+++ b/angular_analyzer_plugin/lib/errors.dart
@@ -26,6 +26,8 @@
   AngularWarningCode.NONEXIST_TWO_WAY_OUTPUT_BOUND,
   AngularWarningCode.TWO_WAY_BINDING_OUTPUT_TYPE_ERROR,
   AngularWarningCode.INPUT_BINDING_TYPE_ERROR,
+  AngularWarningCode.ATTR_IF_BINDING_TYPE_ERROR,
+  AngularWarningCode.UNMATCHED_ATTR_IF_BINDING,
   AngularWarningCode.TRAILING_EXPRESSION,
   AngularWarningCode.OUTPUT_MUST_BE_STREAM,
   AngularWarningCode.TWO_WAY_BINDING_NOT_ASSIGNABLE,
@@ -229,6 +231,19 @@
       'Attribute value expression (of type {0}) is not assignable to component'
       ' input (of type {1})');
 
+  /// An error code indicating that an [attr.foo.if] binding was bound to an
+  /// expression that was not of type bool
+  static const ATTR_IF_BINDING_TYPE_ERROR = const AngularWarningCode(
+      'ATTR_IF_BINDING_TYPE_ERROR',
+      'Attribute value expression (of type {0}) must be of type bool');
+
+  /// An error code indicating that an [attr.foo.if] binding was used, but no
+  /// [attr.foo] binding simultaneously occured.
+  static const UNMATCHED_ATTR_IF_BINDING = const AngularWarningCode(
+      'UNMATCHED_ATTR_IF_BINDING',
+      'attr-if binding for attr {0} does not have a corresponding'
+      ' [attr.{0}] binding for it to affect');
+
   /// An error code indicating that an expression did not correctly
   /// end with an EOF token.
   static const TRAILING_EXPRESSION = const AngularWarningCode(
diff --git a/angular_analyzer_plugin/lib/src/converter.dart b/angular_analyzer_plugin/lib/src/converter.dart
index bfcd7f4..695685a 100644
--- a/angular_analyzer_plugin/lib/src/converter.dart
+++ b/angular_analyzer_plugin/lib/src/converter.dart
@@ -732,23 +732,40 @@
     var propNameOffset = nameToken.offset;
 
     if (ast is ParsedPropertyAst) {
-      final name = ast.name;
+      // For some inputs, like `[class.foo]`, the [ast.name] here is actually
+      // not a name, but a prefix. If so, use the [ast.postfix] as the [name] of
+      // the [ExpressionBoundAttribute] we're creating here, by changing
+      // [propName].
+      final nameOrPrefix = ast.name;
+
       if (ast.postfix != null) {
-        var replacePropName = false;
-        if (name == 'class') {
+        var usePostfixForName = false;
+        var preserveUnitInName = false;
+
+        if (nameOrPrefix == 'class') {
           bound = ExpressionBoundType.clazz;
-          replacePropName = true;
-        } else if (name == 'attr') {
-          bound = ExpressionBoundType.attr;
-          replacePropName = true;
-        } else if (name == 'style') {
+          usePostfixForName = true;
+          preserveUnitInName = true;
+        } else if (nameOrPrefix == 'attr') {
+          if (ast.unit == 'if') {
+            bound = ExpressionBoundType.attrIf;
+          } else {
+            bound = ExpressionBoundType.attr;
+          }
+          usePostfixForName = true;
+        } else if (nameOrPrefix == 'style') {
           bound = ExpressionBoundType.style;
-          replacePropName = true;
+          usePostfixForName = true;
+          preserveUnitInName = ast.unit != null;
         }
-        if (replacePropName) {
-          final _unitName = ast.unit == null ? '' : '.${ast.unit}';
+
+        if (usePostfixForName) {
+          final _unitName =
+              preserveUnitInName && ast.unit != null ? '.${ast.unit}' : '';
           propName = '${ast.postfix}$_unitName';
-          propNameOffset = nameToken.offset + name.length + '.'.length;
+          propNameOffset = nameToken.offset + ast.name.length + '.'.length;
+        } else {
+          assert(!preserveUnitInName);
         }
       }
     } else {
diff --git a/angular_analyzer_plugin/lib/src/resolver.dart b/angular_analyzer_plugin/lib/src/resolver.dart
index 294f667..cf964a0 100644
--- a/angular_analyzer_plugin/lib/src/resolver.dart
+++ b/angular_analyzer_plugin/lib/src/resolver.dart
@@ -1300,6 +1300,8 @@
       _resolveStyleAttribute(attribute);
     } else if (attribute.bound == ExpressionBoundType.attr) {
       _resolveAttributeBoundAttribute(attribute);
+    } else if (attribute.bound == ExpressionBoundType.attrIf) {
+      _resolveAttributeBoundAttributeIf(attribute);
     }
   }
 
@@ -1448,6 +1450,55 @@
     // failing tests for this)
   }
 
+  /// Resolve attributes of type [attribute.some-attribute]="someExpr"
+  void _resolveAttributeBoundAttributeIf(ExpressionBoundAttribute attribute) {
+    if (attribute.parent is! ElementInfo) {
+      assert(false, 'Got an attr-if bound attribute on non element! Aborting!');
+      return;
+    }
+
+    final parent = attribute.parent as ElementInfo;
+
+    // For the [attr.foo.if] attribute, find the matching [attr.foo] attribute.
+    final matchingAttr = parent.attributes
+        .where((attr) =>
+            attr is ExpressionBoundAttribute &&
+            attr.bound == ExpressionBoundType.attr)
+        .firstWhere((attrAttr) => attrAttr.name == attribute.name,
+            orElse: () => null);
+
+    // Error: no matching attribute to make conditional via this attr-if.
+    if (matchingAttr == null) {
+      errorListener.onError(new AnalysisError(
+          templateSource,
+          attribute.nameOffset,
+          attribute.name.length,
+          AngularWarningCode.UNMATCHED_ATTR_IF_BINDING,
+          [attribute.name]));
+      return;
+    }
+
+    // Add navigation from [attribute] (`[attr.foo.if]`) to [matchingAttr]
+    // (`[attr.foo]`).
+    final range = new SourceRange(attribute.nameOffset, attribute.name.length);
+    template.addRange(
+        range,
+        new AngularElementImpl('attr.${attribute.name}',
+            matchingAttr.nameOffset, matchingAttr.name.length, templateSource));
+
+    // Ensure the if condition was a boolean.
+    if (attribute.expression != null &&
+        !typeSystem.isAssignableTo(
+            attribute.expression.staticType, typeProvider.boolType)) {
+      errorListener.onError(new AnalysisError(
+          templateSource,
+          attribute.valueOffset,
+          attribute.value.length,
+          AngularWarningCode.ATTR_IF_BINDING_TYPE_ERROR,
+          [attribute.name]));
+    }
+  }
+
   /// Resolve attributes of type [class.some-class]="someBoolExpr", ensuring
   /// the class is a valid css identifier and that the expression is of boolean
   /// type
diff --git a/angular_analyzer_plugin/test/navigation_test.dart b/angular_analyzer_plugin/test/navigation_test.dart
index 939f42b..e481388 100644
--- a/angular_analyzer_plugin/test/navigation_test.dart
+++ b/angular_analyzer_plugin/test/navigation_test.dart
@@ -212,6 +212,33 @@
   }
 
   // ignore: non_constant_identifier_names
+  Future test_navigate_attrIf() async {
+    code = r'''
+import 'package:angular/src/core/metadata.dart';
+
+@Component(selector: 'foo', template: r"""
+<div
+  [attr.foo]="123"
+  [attr.foo.if]="true">
+</div>
+""")
+class TextPanel {
+}
+''';
+    final source = newSource('/test.dart', code);
+    // compute navigation regions
+    final result = await resolveDart(source);
+    new AngularNavigation(angularDriver.contentOverlay).computeNavigation(
+        new AngularNavigationRequest(null, null, null, result), collector,
+        templatesOnly: false);
+    {
+      _findRegionString('foo', '.if');
+      expect(targetLocation.file, '/test.dart');
+      expect(targetLocation.offset, code.indexOf('foo]'));
+    }
+  }
+
+  // ignore: non_constant_identifier_names
   Future test_navigateOnfocusin() async {
     code = r'''
 import 'package:angular/src/core/metadata.dart';
diff --git a/angular_analyzer_plugin/test/resolver_test.dart b/angular_analyzer_plugin/test/resolver_test.dart
index 6f19d92..2900752 100644
--- a/angular_analyzer_plugin/test/resolver_test.dart
+++ b/angular_analyzer_plugin/test/resolver_test.dart
@@ -351,6 +351,92 @@
   }
 
   // ignore: non_constant_identifier_names
+  Future test_expression_attrBindingIf_attrNotBound() async {
+    _addDartSource(r'''
+@Component(selector: 'test-panel', templateUrl: 'test_panel.html')
+class TestPanel {
+  bool cond;
+}
+''');
+    final code = r"""
+<span [attr.foo.if]='cond'></span>
+""";
+    _addHtmlSource(code);
+    await _resolveSingleTemplate(dartSource);
+    assertErrorInCodeAtPosition(
+        AngularWarningCode.UNMATCHED_ATTR_IF_BINDING, code, 'foo');
+  }
+
+  // ignore: non_constant_identifier_names
+  Future test_expression_attrBindingIf_empty() async {
+    _addDartSource(r'''
+@Component(selector: 'test-panel', templateUrl: 'test_panel.html')
+class TestPanel {
+  String text1;
+}
+''');
+    final code = r"""
+<span [attr.foo]='text1' [attr.foo.if]></span>
+""";
+    _addHtmlSource(code);
+    await _resolveSingleTemplate(dartSource);
+    assertErrorInCodeAtPosition(
+        AngularWarningCode.EMPTY_BINDING, code, '[attr.foo.if]');
+  }
+
+  // ignore: non_constant_identifier_names
+  Future test_expression_attrBindingIf_emptyWithQuotes() async {
+    _addDartSource(r'''
+@Component(selector: 'test-panel', templateUrl: 'test_panel.html')
+class TestPanel {
+  String text1;
+}
+''');
+    final code = r"""
+<span [attr.foo]='text1' [attr.foo.if]=''></span>
+""";
+    _addHtmlSource(code);
+    await _resolveSingleTemplate(dartSource);
+    assertErrorInCodeAtPosition(
+        AngularWarningCode.EMPTY_BINDING, code, '[attr.foo.if]');
+  }
+
+  // ignore: non_constant_identifier_names
+  Future test_expression_attrBindingIf_typeError() async {
+    _addDartSource(r'''
+@Component(selector: 'test-panel', templateUrl: 'test_panel.html')
+class TestPanel {
+  String text1;
+  String text2;
+}
+''');
+    final code = r"""
+<span [attr.foo]='text1' [attr.foo.if]='text2'></span>
+""";
+    _addHtmlSource(code);
+    await _resolveSingleTemplate(dartSource);
+    assertErrorInCodeAtPosition(
+        AngularWarningCode.ATTR_IF_BINDING_TYPE_ERROR, code, 'text2');
+  }
+
+  // ignore: non_constant_identifier_names
+  Future test_expression_attrBindingIf_valid() async {
+    _addDartSource(r'''
+@Component(selector: 'test-panel', templateUrl: 'test_panel.html')
+class TestPanel {
+  String text;
+  bool cond;
+}
+''');
+    final code = r"""
+<span [attr.foo]='text' [attr.foo.if]='cond'></span>
+""";
+    _addHtmlSource(code);
+    await _resolveSingleTemplate(dartSource);
+    errorListener.assertNoErrors();
+  }
+
+  // ignore: non_constant_identifier_names
   Future test_expression_await_not_allowed() async {
     _addDartSource(r'''
 @Component(selector: 'test-panel', templateUrl: 'test_panel.html')