5 min read

Tags

About a year ago, I wrote about including quickfixes for Lint rules. Quick fixes appear on the context menu when Lint flags an error and allows developers to quickly address the issue. They can be applied by clicking on the link at the bottom of the dialog or pressing ALT+ENTER (⌥ + ↩) and then choosing the fix.


Hovering over the error brings up a dialog with the quickfix option available

Pressing ALT+ENTER on the error brings up a menu with available options

The images above show the rule we have in our project for data binding expression formatting. Applying the quickfix results into putting one whitespace in between the braces and the expression itself:

android:text="@{ label }"

On the outset, it looks like writing this quickfix is pretty straightforward – look for the opening brace ({), insert a space if needed, put back the expression, insert a space if needed, put back the closing brace (}). But say we have this XML layout: (This is an overly simplified example for illustration purposes, so keep your comments to yourself)

Notice how in line 17, the && characters are escaped (&&). This is because whilst data binding allows the usage of logical operators like &&, some characters should always be escaped for our XML file to be syntactically correct.

The rule :straight_ruler:

Our Lint rule checks all attributes in an XML layout file:

override fun getApplicableAttributes(): Collection<String>? {
    return XmlScannerConstants.ALL
}

And when an attribute is encountered, our callback gets triggered:

override fun visitAttribute(context: XmlContext, attribute: Attr)

We can then check if the attribute’s value is a properly formatted data binding expression:

// An `@` character, an optional `=` character, an opening brace `{`, a space, the expression, a space, a closing brace `}`
val validPattern = Regex("@=?\\{\\s.*\\s}")

if (isDataBindingExpression(attributeValue) && !attributeValue.matches(validPattern)) {
    // Report the issue
}

In our example layout file above, our Lint rule will flag that line 17 is improperly formatted when it visits the app:visible attribute:

app:visible="@{hasValue &amp;&amp; isFeatureOn}"

The Attr interface inherits from the Node interface, so there are some methods that we can use to figure out information about the attribute. For Lint in particular, there is a method getValueLocation which we can theoretically use to get the Location for this node’s value.

Remember, Location is important because it lets us tell Lint where to put the squiggly red lines to highlight the error and also the place in the file where we want to replace the incorrect value.

I feel a “But…” coming :raising_hand:

Using the Location returned by getValueLocation, we expect the whole value part of the attribute to be highlighted. Let’s go ahead and use it to report the issue before we build our quickfix:

if (isDataBindingExpression(attributeValue) && !attributeValue.matches(validPattern)) {
    // Report the issue
    context.report(
        issue = DatabindingExpressionFormatDetector.ISSUE,
        scope = attribute,
        location = context.getValueLocation(attribute),
        message = "Please put one whitespace between the braces and the expression"
    )
}

If we run our test for this layout file, this is what we get.

res/layout/layout.xml:17: Warning: Please put one whitespace between the braces and the expression [DatabindingExpressionFormat]
        app:visible="@{hasValue &amp;&amp; isFeatureOn}" />
                             ~~~~~~~~~~~~~~~~~~~~~~~~~~
0 errors, 1 warnings

Not quite right. :thinking: To understand what is going on, let’s look at what happens in the getValueLocation implementation. We can see that somewhere in there, the LintClient retrieves the value of the node:

int length = node.getValue().length();

Now if we go back to the W3 documentation on Attr:

The following table gives some examples of the relations between the attribute value in the original document (parsed attribute), the value as exposed in the DOM, and the serialization of the value:

Examples Parsed attribute value Initial Attr.value Serialized attribute value  
Character reference "x&#178;=5" "x²=5" "x&#178;=5"  
Built-in character entity "y&lt;6" "y<6" "y&lt;6"  
Literal newline between "x=5&#10;y=6" "x=5 y=6" "x=5&#10;y=6"  
Normalized newline between "x=5 y=6" "x=5 y=6" "x=5 y=6"  
Entity e with literal newline <!ENTITY e '...&#10;...'> [...]> "x=5&e;y=6" Dependent on Implementation and Load Options Dependent on Implementation and Load/Save Options  

This is important because it says that if there are escaped characters, the resolved characters will be returned when we get the attribute’s value.

val attrValue = node.getValue() // returns "@{hasValue && isFeatureOn}"

And we can actually see the effects of this. The red squiggly lines are off by eight characters:

&amp;&amp; -> 10 characters
&& -> 2 characters

Finding the right Location :compass:

This means that we need to find the correct Location on our own.

// This is the contents of the whole XML file
val rawText = context.getContents() ?: return Pair(null, null)

// Find where the node bounds
val nodeStart = context.parser.getNodeStartOffset(context, attribute)
val nodeEnd = context.parser.getNodeEndOffset(context, attribute)

// The full contents of the node, including the attribute name (i.e. `app:visible="@{hasValue &amp;&amp; isFeatureOn}"`)
val rawNodeText = rawText.substring(nodeStart, nodeEnd)

Let’s now find the offset of the start of the expression (remember we cannot rely on the length of attribute.value because of escaped characters). Since we are working with databinding expressions, we can rely on the syntax to find where the value starts:

// Find the databinding prefix used
val prefixExpression = if (rawNodeText.contains(SdkConstants.PREFIX_TWOWAY_BINDING_EXPR)) {
    SdkConstants.PREFIX_TWOWAY_BINDING_EXPR
} else SdkConstants.PREFIX_BINDING_EXPR

// First character after the opening `"` in the attribute value
val attributeValueStart = rawNodeText.indexOf(prefixExpression)

Now that we know where the attribute’s value starts and where the whole node ends, we can finally construct an accurate Location for the attribute value:

// We cannot use `context.parser.getValueLocation()` since there may be escaped characters
// Get the Location value for the actual expression within the file (not including the quotation marks)
val attributeValueLocation = Location.create(context.file, rawText, nodeStart + attributeValueStart, nodeEnd - 1)

The quickfix :woman_mechanic:

When reporting a Lint issue, we can provide our users a quickfix which enables to, uhm, quickly fix the reported issue. For this rule, if either the leading and/or trailing space in the expression is missing, our quickfix will insert those into the file.

Lint has a bunch of available types of fixes that can help us do what we want. In this case, I opted to use replace() and do a straight up string replacement. Of course, as with anything Lint, there are multiple ways to do this (including using regular expressions), but I chose the path of least resistance.

We can now use the Location information we used earlier to write our quickfix:

// Get the actual databinding expression (i.e., what is between `{` and `}`)
val expressionStart = attributeValueStart + prefixExpression.length // length of the expression marker
val expressionEnd = rawNodeText.lastIndexOf("}")
val rawExpressionValue = rawNodeText.substring(expressionStart, expressionEnd)

// Formulate the replacement
val replacementText = "$prefixExpression ${rawExpressionValue.trim()} }"

// Build the quickfix
val quickfix = fix()
    .name("Fix databinding expression formatting")
    .replace()
    .range(attributeValueLocation)
    .with(replacementText)
    .build()

And with that, we’re done! The full source code for this Detector as well as the tests are on Github.