blob: 2d2dbf14488ac1b033d9a3d0e8c607597a57ea60 [file] [log] [blame]
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.tools.lint.checks;
import static com.android.SdkConstants.ATTR_NAME;
import static com.android.SdkConstants.ATTR_TRANSLATABLE;
import static com.android.SdkConstants.TAG_PLURALS;
import static com.android.SdkConstants.TAG_STRING;
import static com.android.SdkConstants.TAG_STRING_ARRAY;
import com.android.annotations.NonNull;
import com.android.resources.ResourceFolderType;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.ResourceXmlDetector;
import com.android.tools.lint.detector.api.Scope;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.XmlContext;
import com.android.utils.SdkUtils;
import com.google.common.annotations.VisibleForTesting;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.w3c.dom.Attr;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
/** Checks for various typographical issues in string definitions. */
public class TypographyDetector extends ResourceXmlDetector {
private static final Implementation IMPLEMENTATION =
new Implementation(TypographyDetector.class, Scope.RESOURCE_FILE_SCOPE);
/** Replace hyphens with dashes? */
public static final Issue DASHES =
Issue.create(
"TypographyDashes",
"Hyphen can be replaced with dash",
"The \"n dash\" (\u2013, –) and the \"m dash\" (\u2014, —) "
+ "characters are used for ranges (n dash) and breaks (m dash). Using these "
+ "instead of plain hyphens can make text easier to read and your application "
+ "will look more polished.",
Category.TYPOGRAPHY,
5,
Severity.WARNING,
IMPLEMENTATION)
.addMoreInfo("http://en.wikipedia.org/wiki/Dash");
/** Replace dumb quotes with smart quotes? */
public static final Issue QUOTES =
Issue.create(
"TypographyQuotes",
"Straight quotes can be replaced with curvy quotes",
"Straight single quotes and double quotes, when used as a pair, can be replaced "
+ "by \"curvy quotes\" (or directional quotes). This can make the text more "
+ "readable.\n"
+ "\n"
+ "Note that you should never use grave accents and apostrophes to quote, "
+ "`like this'.\n"
+ "\n"
+ "(Also note that you should not use curvy quotes for code fragments.)",
Category.TYPOGRAPHY,
5,
Severity.WARNING,
IMPLEMENTATION)
.addMoreInfo("http://en.wikipedia.org/wiki/Quotation_mark")
.
// This feature is apparently controversial: recent apps have started using
// straight quotes to avoid inconsistencies. Disabled by default for now.
setEnabledByDefault(false);
/** Replace fraction strings with fraction characters? */
public static final Issue FRACTIONS =
Issue.create(
"TypographyFractions",
"Fraction string can be replaced with fraction character",
"You can replace certain strings, such as 1/2, and 1/4, with dedicated "
+ "characters for these, such as \u00BD (½) and \u00BC (¼). "
+ "This can help make the text more readable.",
Category.TYPOGRAPHY,
5,
Severity.WARNING,
IMPLEMENTATION)
.addMoreInfo("http://en.wikipedia.org/wiki/Number_Forms");
/** Replace ... with the ellipsis character? */
public static final Issue ELLIPSIS =
Issue.create(
"TypographyEllipsis",
"Ellipsis string can be replaced with ellipsis character",
"You can replace the string \"...\" with a dedicated ellipsis character, "
+ "ellipsis character (\u2026, …). This can help make the text more readable.",
Category.TYPOGRAPHY,
5,
Severity.WARNING,
IMPLEMENTATION)
.addMoreInfo("http://en.wikipedia.org/wiki/Ellipsis");
/** The main issue discovered by this detector */
public static final Issue OTHER =
Issue.create(
"TypographyOther",
"Other typographical problems",
"This check looks for miscellaneous typographical problems and offers replacement "
+ "sequences that will make the text easier to read and your application more "
+ "polished.",
Category.TYPOGRAPHY,
3,
Severity.WARNING,
IMPLEMENTATION);
private static final String GRAVE_QUOTE_MESSAGE =
"Avoid quoting with grave accents; use apostrophes or better yet directional quotes instead";
private static final String ELLIPSIS_MESSAGE =
"Replace \"...\" with ellipsis character (\u2026, …) ?";
private static final String EN_DASH_MESSAGE =
"Replace \"-\" with an \"en dash\" character (\u2013, –) ?";
private static final String EM_DASH_MESSAGE =
"Replace \"--\" with an \"em dash\" character (\u2014, —) ?";
private static final String TYPOGRAPHIC_APOSTROPHE_MESSAGE =
"Replace apostrophe (') with typographic apostrophe (\u2019, ’) ?";
private static final String SINGLE_QUOTE_MESSAGE =
"Replace straight quotes ('') with directional quotes (\u2018\u2019, ‘ and ’) ?";
private static final String DBL_QUOTES_MESSAGE =
"Replace straight quotes (\") with directional quotes (\u201C\u201D, “ and ”) ?";
private static final String COPYRIGHT_MESSAGE =
"Replace (c) with copyright symbol \u00A9 (©) ?";
/**
* Pattern used to detect scenarios which can be replaced with n dashes: a numeric range with a
* hyphen in the middle (and possibly spaces)
*/
@VisibleForTesting
static final Pattern HYPHEN_RANGE_PATTERN = Pattern.compile(".*(\\d+\\s*)-(\\s*\\d+).*");
/**
* Pattern used to detect scenarios where a grave accent mark is used to do ASCII quotations of
* the form `this'' or ``this'', which is frowned upon. This pattern tries to avoid falsely
* complaining about strings like "Type Option-` then 'Escape'."
*/
@VisibleForTesting
static final Pattern GRAVE_QUOTATION =
Pattern.compile("(^[^`]*`[^'`]+'[^']*$)|(^[^`]*``[^'`]+''[^']*$)");
/**
* Pattern used to detect common fractions, e.g. 1/2, 1/3, 2/3, 1/4, 3/4 and variations like 2 /
* 3, but not 11/22 and so on.
*/
@VisibleForTesting
static final Pattern FRACTION_PATTERN = Pattern.compile(".*\\b([13])\\s*/\\s*([234])\\b.*");
/**
* Pattern used to detect single quote strings, such as 'hello', but not just quoted strings
* like 'Double quote: "', and not sentences where there are multiple apostrophes but not in a
* quoting context such as "Mind Your P's and Q's".
*/
@VisibleForTesting static final Pattern SINGLE_QUOTE = Pattern.compile(".*\\W*'[^']+'(\\W.*)?");
private static final String FRACTION_MESSAGE =
"Use fraction character %1$c (%2$s) instead of %3$s ?";
private static final String FRACTION_MESSAGE_PATTERN =
"Use fraction character (.+) \\((.+)\\) instead of (.+) \\?";
private boolean mCheckDashes;
private boolean mCheckQuotes;
private boolean mCheckFractions;
private boolean mCheckEllipsis;
private boolean mCheckMisc;
/** Constructs a new {@link TypographyDetector} */
public TypographyDetector() {}
@Override
public boolean appliesTo(@NonNull ResourceFolderType folderType) {
return folderType == ResourceFolderType.VALUES;
}
@Override
public Collection<String> getApplicableElements() {
return Arrays.asList(TAG_STRING, TAG_STRING_ARRAY, TAG_PLURALS);
}
@Override
public void beforeCheckRootProject(@NonNull Context context) {
mCheckDashes = context.isEnabled(DASHES);
mCheckQuotes = context.isEnabled(QUOTES);
mCheckFractions = context.isEnabled(FRACTIONS);
mCheckEllipsis = context.isEnabled(ELLIPSIS);
mCheckMisc = context.isEnabled(OTHER);
}
@Override
public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
// Don't make typography suggestions on strings that are either
// service keys, or are non-translatable (these are typically also
// service keys)
String name = element.getAttribute(ATTR_NAME);
if (SdkUtils.isServiceKey(name)) {
return;
}
Attr translatable = element.getAttributeNode(ATTR_TRANSLATABLE);
if (translatable != null && !Boolean.valueOf(translatable.getValue())) {
return;
}
NodeList childNodes = element.getChildNodes();
for (int i = 0, n = childNodes.getLength(); i < n; i++) {
Node child = childNodes.item(i);
if (child.getNodeType() == Node.TEXT_NODE) {
String text = child.getNodeValue();
checkText(context, element, child, text);
} else if (child.getNodeType() == Node.ELEMENT_NODE
&& (child.getParentNode().getNodeName().equals(TAG_STRING_ARRAY)
|| child.getParentNode().getNodeName().equals(TAG_PLURALS))) {
// String array or plural item children
NodeList items = child.getChildNodes();
for (int j = 0, m = items.getLength(); j < m; j++) {
Node item = items.item(j);
if (item.getNodeType() == Node.TEXT_NODE) {
String text = item.getNodeValue();
checkText(context, child, item, text);
}
}
}
}
}
private void checkText(XmlContext context, Node element, Node textNode, String text) {
if (mCheckEllipsis) {
// Replace ... with ellipsis character?
int ellipsis = text.indexOf("...");
if (ellipsis != -1 && !text.startsWith(".", ellipsis + 3)) {
context.report(ELLIPSIS, element, context.getLocation(textNode), ELLIPSIS_MESSAGE);
}
}
// Dashes
if (mCheckDashes) {
int hyphen = text.indexOf('-');
if (hyphen != -1) {
// n dash
Matcher matcher = HYPHEN_RANGE_PATTERN.matcher(text);
if (matcher.matches()) {
// Make sure that if there is no space before digit there isn't
// one on the left either -- since we don't want to consider
// "1 2 -3" as a range from 2 to 3
boolean isNegativeNumber =
!Character.isWhitespace(matcher.group(2).charAt(0))
&& Character.isWhitespace(
matcher.group(1).charAt(matcher.group(1).length() - 1));
if (!isNegativeNumber && !isAnalyticsTrackingId((Element) element)) {
context.report(
DASHES, element, context.getLocation(textNode), EN_DASH_MESSAGE);
}
}
// m dash
int emdash = text.indexOf("--");
// Don't suggest replacing -- or "--" with an m dash since these are sometimes
// used as digit marker strings
if (emdash > 1 && !text.startsWith("-", emdash + 2)) {
context.report(DASHES, element, context.getLocation(textNode), EM_DASH_MESSAGE);
}
}
}
if (mCheckQuotes) {
// Check for single quotes that can be replaced with directional quotes
int quoteStart = text.indexOf('\'');
if (quoteStart != -1) {
int quoteEnd = text.indexOf('\'', quoteStart + 1);
if (quoteEnd != -1
&& quoteEnd > quoteStart + 1
&& (quoteEnd < text.length() - 1 || quoteStart > 0)
&& SINGLE_QUOTE.matcher(text).matches()) {
context.report(
QUOTES, element, context.getLocation(textNode), SINGLE_QUOTE_MESSAGE);
return;
}
// Check for apostrophes that can be replaced by typographic apostrophes
if (quoteEnd == -1
&& quoteStart > 0
&& Character.isLetterOrDigit(text.charAt(quoteStart - 1))) {
context.report(
QUOTES,
element,
context.getLocation(textNode),
TYPOGRAPHIC_APOSTROPHE_MESSAGE);
return;
}
}
// Check for double quotes that can be replaced by directional double quotes
quoteStart = text.indexOf('"');
if (quoteStart != -1) {
int quoteEnd = text.indexOf('"', quoteStart + 1);
if (quoteEnd != -1 && quoteEnd > quoteStart + 1) {
if (quoteEnd < text.length() - 1 || quoteStart > 0) {
context.report(
QUOTES, element, context.getLocation(textNode), DBL_QUOTES_MESSAGE);
return;
}
}
}
// Check for grave accent quotations
if (text.indexOf('`') != -1 && GRAVE_QUOTATION.matcher(text).matches()) {
// Are we indenting ``like this'' or `this' ? If so, complain
context.report(QUOTES, element, context.getLocation(textNode), GRAVE_QUOTE_MESSAGE);
return;
}
// Consider suggesting other types of directional quotes, such as guillemets, in
// other languages?
// There are a lot of exceptions and special cases to be considered so
// this will need careful implementation and testing.
// See http://en.wikipedia.org/wiki/Non-English_usage_of_quotation_marks
}
// Fraction symbols?
if (mCheckFractions && text.indexOf('/') != -1) {
Matcher matcher = FRACTION_PATTERN.matcher(text);
if (matcher.matches()) {
String top = matcher.group(1); // Numerator
String bottom = matcher.group(2); // Denominator
if (top.equals("1") && bottom.equals("2")) {
context.report(
FRACTIONS,
element,
context.getLocation(textNode),
String.format(FRACTION_MESSAGE, '\u00BD', "&#189;", "1/2"));
} else if (top.equals("1") && bottom.equals("4")) {
context.report(
FRACTIONS,
element,
context.getLocation(textNode),
String.format(FRACTION_MESSAGE, '\u00BC', "&#188;", "1/4"));
} else if (top.equals("3") && bottom.equals("4")) {
context.report(
FRACTIONS,
element,
context.getLocation(textNode),
String.format(FRACTION_MESSAGE, '\u00BE', "&#190;", "3/4"));
} else if (top.equals("1") && bottom.equals("3")) {
context.report(
FRACTIONS,
element,
context.getLocation(textNode),
String.format(FRACTION_MESSAGE, '\u2153', "&#8531;", "1/3"));
} else if (top.equals("2") && bottom.equals("3")) {
context.report(
FRACTIONS,
element,
context.getLocation(textNode),
String.format(FRACTION_MESSAGE, '\u2154', "&#8532;", "2/3"));
}
}
}
if (mCheckMisc) {
// Fix copyright symbol?
if (text.indexOf('(') != -1 && (text.contains("(c)") || text.contains("(C)"))) {
// Suggest replacing with copyright symbol?
context.report(OTHER, element, context.getLocation(textNode), COPYRIGHT_MESSAGE);
// Replace (R) and TM as well? There are unicode characters for these but they
// are probably not very common within Android app strings.
}
}
}
private static boolean isAnalyticsTrackingId(Element element) {
String name = element.getAttribute(ATTR_NAME);
return "ga_trackingId".equals(name);
}
/**
* An object describing a single edit to be made. The offset points to a location to start
* editing; the length is the number of characters to delete, and the replaceWith string points
* to a string to insert at the offset. Note that this can model not just replacement edits but
* deletions (empty replaceWith) and insertions (replace length = 0) too.
*/
public static class ReplaceEdit {
/** The offset of the edit */
public final int offset;
/** The number of characters to delete at the offset */
public final int length;
/** The characters to insert at the offset */
public final String replaceWith;
/**
* Creates a new replace edit
*
* @param offset the offset of the edit
* @param length the number of characters to delete at the offset
* @param replaceWith the characters to insert at the offset
*/
public ReplaceEdit(int offset, int length, String replaceWith) {
super();
this.offset = offset;
this.length = length;
this.replaceWith = replaceWith;
}
}
/**
* Returns a list of edits to be applied to fix the suggestion made by the given warning. The
* specific issue id and message should be the message provided by this detector in an earlier
* run.
*
* <p>This is intended to help tools implement automatic fixes of these warnings. The reason
* only the message and issue id can be provided instead of actual state passed in the data
* field to a reporter is that fix operation can be run much later than the lint is processed
* (for example, in a subsequent run of the IDE when only the warnings have been persisted),
*
* @param issueId the issue id, which should be the id for one of the typography issues
* @param message the actual error message, which should be a message provided by this detector
* @param textNode a text node which corresponds to the text node the warning operated on
* @return a list of edits, which is never null but could be empty. The offsets in the edit
* objects are relative to the text node.
*/
public static List<ReplaceEdit> getEdits(String issueId, String message, Node textNode) {
return getEdits(issueId, message, textNode.getNodeValue());
}
/**
* Returns a list of edits to be applied to fix the suggestion made by the given warning. The
* specific issue id and message should be the message provided by this detector in an earlier
* run.
*
* <p>This is intended to help tools implement automatic fixes of these warnings. The reason
* only the message and issue id can be provided instead of actual state passed in the data
* field to a reporter is that fix operation can be run much later than the lint is processed
* (for example, in a subsequent run of the IDE when only the warnings have been persisted),
*
* @param issueId the issue id, which should be the id for one of the typography issues
* @param message the actual error message, which should be a message provided by this detector
* @param text the text of the XML node where the warning appeared
* @return a list of edits, which is never null but could be empty. The offsets in the edit
* objects are relative to the text node.
*/
public static List<ReplaceEdit> getEdits(String issueId, String message, String text) {
List<ReplaceEdit> edits = new ArrayList<>();
if (message.equals(ELLIPSIS_MESSAGE)) {
int offset = text.indexOf("...");
if (offset != -1) {
edits.add(new ReplaceEdit(offset, 3, "\u2026"));
}
} else if (message.equals(EN_DASH_MESSAGE)) {
int offset = text.indexOf('-');
if (offset != -1) {
edits.add(new ReplaceEdit(offset, 1, "\u2013"));
}
} else if (message.equals(EM_DASH_MESSAGE)) {
int offset = text.indexOf("--");
if (offset != -1) {
edits.add(new ReplaceEdit(offset, 2, "\u2014"));
}
} else if (message.equals(TYPOGRAPHIC_APOSTROPHE_MESSAGE)) {
int offset = text.indexOf('\'');
if (offset != -1) {
edits.add(new ReplaceEdit(offset, 1, "\u2019"));
}
} else if (message.equals(COPYRIGHT_MESSAGE)) {
int offset = text.indexOf("(c)");
if (offset == -1) {
offset = text.indexOf("(C)");
}
if (offset != -1) {
edits.add(new ReplaceEdit(offset, 3, "\u00A9"));
}
} else if (message.equals(SINGLE_QUOTE_MESSAGE)) {
int offset = text.indexOf('\'');
if (offset != -1) {
int endOffset = text.indexOf('\'', offset + 1);
if (endOffset != -1) {
edits.add(new ReplaceEdit(offset, 1, "\u2018"));
edits.add(new ReplaceEdit(endOffset, 1, "\u2019"));
}
}
} else if (message.equals(DBL_QUOTES_MESSAGE)) {
int offset = text.indexOf('"');
if (offset != -1) {
int endOffset = text.indexOf('"', offset + 1);
if (endOffset != -1) {
edits.add(new ReplaceEdit(offset, 1, "\u201C"));
edits.add(new ReplaceEdit(endOffset, 1, "\u201D"));
}
}
} else if (message.equals(GRAVE_QUOTE_MESSAGE)) {
int offset = text.indexOf('`');
if (offset != -1) {
int endOffset = text.indexOf('\'', offset + 1);
if (endOffset != -1) {
edits.add(new ReplaceEdit(offset, 1, "\u2018"));
edits.add(new ReplaceEdit(endOffset, 1, "\u2019"));
}
}
} else {
Matcher matcher = Pattern.compile(FRACTION_MESSAGE_PATTERN).matcher(message);
if (matcher.find()) {
// "Use fraction character %1$c (%2$s) instead of %3$s ?";
String replace = matcher.group(3);
int offset = text.indexOf(replace);
if (offset != -1) {
String replaceWith = matcher.group(2);
edits.add(new ReplaceEdit(offset, replace.length(), replaceWith));
}
}
}
return edits;
}
}