blob: 420d315dd743a86e319d63c88b1c08a1ea6f1ce5 [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.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.annotations.VisibleForTesting;
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.Speed;
import com.android.tools.lint.detector.api.XmlContext;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
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;
/**
* 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", //$NON-NLS-1$
"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"); //$NON-NLS-1$
/** Replace dumb quotes with smart quotes? */
public static final Issue QUOTES = Issue.create(
"TypographyQuotes", //$NON-NLS-1$
"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"). //$NON-NLS-1$
// 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", //$NON-NLS-1$
"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"); //$NON-NLS-1$
/** Replace ... with the ellipsis character? */
public static final Issue ELLIPSIS = Issue.create(
"TypographyEllipsis", //$NON-NLS-1$
"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"); //$NON-NLS-1$
/** The main issue discovered by this detector */
public static final Issue OTHER = Issue.create(
"TypographyOther", //$NON-NLS-1$
"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+).*"); //$NON-NLS-1$
/**
* 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("(^[^`]*`[^'`]+'[^']*$)|(^[^`]*``[^'`]+''[^']*$)"); //$NON-NLS-1$
/**
* 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.*"); //$NON-NLS-1$
/**
* 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.*)?"); //$NON-NLS-1$
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;
}
@NonNull
@Override
public Speed getSpeed() {
return Speed.FAST;
}
@Override
public Collection<String> getApplicableElements() {
return Arrays.asList(
TAG_STRING,
TAG_STRING_ARRAY,
TAG_PLURALS
);
}
@Override
public void beforeCheckProject(@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) {
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("..."); //$NON-NLS-1$
if (ellipsis != -1 && !text.startsWith(".", ellipsis + 3)) { //$NON-NLS-1$
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("--"); //$NON-NLS-1$
// 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)) { //$NON-NLS-1$
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")) { //$NON-NLS-1$ //$NON-NLS-2$
context.report(FRACTIONS, element, context.getLocation(textNode),
String.format(FRACTION_MESSAGE, '\u00BD', "&#189;", "1/2"));
} else if (top.equals("1") && bottom.equals("4")) { //$NON-NLS-1$ //$NON-NLS-2$
context.report(FRACTIONS, element, context.getLocation(textNode),
String.format(FRACTION_MESSAGE, '\u00BC', "&#188;", "1/4"));
} else if (top.equals("3") && bottom.equals("4")) { //$NON-NLS-1$ //$NON-NLS-2$
context.report(FRACTIONS, element, context.getLocation(textNode),
String.format(FRACTION_MESSAGE, '\u00BE', "&#190;", "3/4"));
} else if (top.equals("1") && bottom.equals("3")) { //$NON-NLS-1$ //$NON-NLS-2$
context.report(FRACTIONS, element, context.getLocation(textNode),
String.format(FRACTION_MESSAGE, '\u2153', "&#8531;", "1/3"));
} else if (top.equals("2") && bottom.equals("3")) { //$NON-NLS-1$ //$NON-NLS-2$
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)"))) { //$NON-NLS-1$ //$NON-NLS-2$
// 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); //$NON-NLS-1$
}
/**
* 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<ReplaceEdit>();
if (message.equals(ELLIPSIS_MESSAGE)) {
int offset = text.indexOf("..."); //$NON-NLS-1$
if (offset != -1) {
edits.add(new ReplaceEdit(offset, 3, "\u2026")); //$NON-NLS-1$
}
} else if (message.equals(EN_DASH_MESSAGE)) {
int offset = text.indexOf('-');
if (offset != -1) {
edits.add(new ReplaceEdit(offset, 1, "\u2013")); //$NON-NLS-1$
}
} else if (message.equals(EM_DASH_MESSAGE)) {
int offset = text.indexOf("--"); //$NON-NLS-1$
if (offset != -1) {
edits.add(new ReplaceEdit(offset, 2, "\u2014")); //$NON-NLS-1$
}
} else if (message.equals(TYPOGRAPHIC_APOSTROPHE_MESSAGE)) {
int offset = text.indexOf('\'');
if (offset != -1) {
edits.add(new ReplaceEdit(offset, 1, "\u2019")); //$NON-NLS-1$
}
} else if (message.equals(COPYRIGHT_MESSAGE)) {
int offset = text.indexOf("(c)"); //$NON-NLS-1$
if (offset == -1) {
offset = text.indexOf("(C)"); //$NON-NLS-1$
}
if (offset != -1) {
edits.add(new ReplaceEdit(offset, 3, "\u00A9")); //$NON-NLS-1$
}
} else if (message.equals(SINGLE_QUOTE_MESSAGE)) {
int offset = text.indexOf('\'');
if (offset != -1) {
int endOffset = text.indexOf('\'', offset + 1); //$NON-NLS-1$
if (endOffset != -1) {
edits.add(new ReplaceEdit(offset, 1, "\u2018")); //$NON-NLS-1$
edits.add(new ReplaceEdit(endOffset, 1, "\u2019")); //$NON-NLS-1$
}
}
} 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")); //$NON-NLS-1$
edits.add(new ReplaceEdit(endOffset, 1, "\u201D")); //$NON-NLS-1$
}
}
} 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")); //$NON-NLS-1$
edits.add(new ReplaceEdit(endOffset, 1, "\u2019")); //$NON-NLS-1$
}
}
} 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;
}
}