diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt index e1be27d8..48466996 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt @@ -12,7 +12,7 @@ import kotlin.math.max * @see * Tag#HASHTAG_RE. */ -private const val TAG_REGEX = "(?:^|[^/)\\w])#([\\w_]*[\\p{Alpha}_][\\w_]*)" +private const val TAG_REGEX = "(?:^|[^/)A-Za-z0-9_])#([\\w_]*[\\p{Alpha}_][\\w_]*)" /** * @see @@ -30,10 +30,10 @@ private val STRICT_WEB_URL_PATTERN = Pattern.compile("(((?:(?i:http|https|rtsp): private val spanClasses = listOf(ForegroundColorSpan::class.java, URLSpan::class.java) private val finders = mapOf( - FoundMatchType.HTTP_URL to PatternFinder(':', HTTP_URL_REGEX, 5), - FoundMatchType.HTTPS_URL to PatternFinder(':', HTTPS_URL_REGEX, 6), - FoundMatchType.TAG to PatternFinder('#', TAG_REGEX, 1), - FoundMatchType.MENTION to PatternFinder('@', MENTION_REGEX, 1) + FoundMatchType.HTTP_URL to PatternFinder(':', HTTP_URL_REGEX, 5, Character::isWhitespace), + FoundMatchType.HTTPS_URL to PatternFinder(':', HTTPS_URL_REGEX, 6, Character::isWhitespace), + FoundMatchType.TAG to PatternFinder('#', TAG_REGEX, 1, ::isValidForTagPrefix), + FoundMatchType.MENTION to PatternFinder('@', MENTION_REGEX, 1, Character::isWhitespace) // TODO: We also need a proper validator for mentions ) private enum class FoundMatchType { @@ -49,7 +49,8 @@ private class FindCharsResult { var end: Int = -1 } -private class PatternFinder(val searchCharacter: Char, regex: String, val searchPrefixWidth: Int) { +private class PatternFinder(val searchCharacter: Char, regex: String, val searchPrefixWidth: Int, + val prefixValidator: (Int) -> Boolean) { val pattern: Pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE) } @@ -67,7 +68,7 @@ private fun findPattern(string: String, fromIndex: Int): FindCharsResult { val finder = finders[matchType] if (finder!!.searchCharacter == c && ((i - fromIndex) < finder.searchPrefixWidth || - Character.isWhitespace(string.codePointAt(i - finder.searchPrefixWidth)))) { + finder.prefixValidator(string.codePointAt(i - finder.searchPrefixWidth)))) { result.matchType = matchType result.start = max(0, i - finder.searchPrefixWidth) findEndOfPattern(string, result, finder.pattern) @@ -87,10 +88,22 @@ private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: P // Once we have API level 26+, we can use named captures... val end = matcher.end() result.start = matcher.start() - if (Character.isWhitespace(string.codePointAt(result.start))) { - ++result.start + when (result.matchType) { + FoundMatchType.TAG -> { + if (isValidForTagPrefix(string.codePointAt(result.start))) { + if (string[result.start] != '#' || + (string[result.start] == '#' && string[result.start + 1] == '#')) { + ++result.start + } + } + } + else -> { + if (Character.isWhitespace(string.codePointAt(result.start))) { + ++result.start + } + } } - when(result.matchType) { + when (result.matchType) { FoundMatchType.HTTP_URL, FoundMatchType.HTTPS_URL -> { // Preliminary url patterns are fast/permissive, now we'll do full validation if (STRICT_WEB_URL_PATTERN.matcher(string.substring(result.start, end)).matches()) { @@ -133,3 +146,16 @@ fun highlightSpans(text: Spannable, colour: Int) { } } } + +private fun isWordCharacters(codePoint: Int): Boolean { + return (codePoint in 0x30..0x39) || // [0-9] + (codePoint in 0x41..0x5a) || // [A-Z] + (codePoint == 0x5f) || // _ + (codePoint in 0x61..0x7a) // [a-z] +} + +private fun isValidForTagPrefix(codePoint: Int): Boolean { + return !(isWordCharacters(codePoint) || // \w + (codePoint == 0x2f) || // / + (codePoint == 0x29)) // ) +} diff --git a/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt b/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt index 66bf73e8..68bdaaa9 100644 --- a/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt @@ -10,11 +10,11 @@ import org.junit.runners.Parameterized class SpanUtilsTest { @Test fun matchesMixedSpans() { - val input = "one #one two: @two three : https://thr.ee/meh?foo=bar&wat=@at#hmm four #four five @five" + val input = "one #one two: @two three : https://thr.ee/meh?foo=bar&wat=@at#hmm four #four five @five ろく#six" val inputSpannable = FakeSpannable(input) highlightSpans(inputSpannable, 0xffffff) val spans = inputSpannable.spans - Assert.assertEquals(5, spans.size) + Assert.assertEquals(6, spans.size) } @Test @@ -93,6 +93,38 @@ class SpanUtilsTest { } } + @RunWith(Parameterized::class) + class HighlightingTestsForTag(private val text: String, + private val expectedStartIndex: Int, + private val expectedEndIndex: Int) { + companion object { + @Parameterized.Parameters(name = "{0}") + @JvmStatic + fun data(): Iterable { + return listOf( + arrayOf("#test", 0, 5), + arrayOf(" #AfterSpace", 1, 12), + arrayOf("#BeforeSpace ", 0, 12), + arrayOf("@#after_at", 1, 10), + arrayOf("あいうえお#after_hiragana", 5, 20), + arrayOf("##DoubleHash", 1, 12), + arrayOf("###TripleHash", 2, 13) + ) + } + } + + @Test + fun matchExpectations() { + val inputSpannable = FakeSpannable(text) + highlightSpans(inputSpannable, 0xffffff) + val spans = inputSpannable.spans + Assert.assertEquals(1, spans.size) + val span = spans.first() + Assert.assertEquals(expectedStartIndex, span.start) + Assert.assertEquals(expectedEndIndex, span.end) + } + } + class FakeSpannable(private val text: String) : Spannable { val spans = mutableListOf()