Merge pull request #3299 from vitorpamplona/claude/url-parser-punctuation-brvq35

Keep balanced closing delimiters in URLs
This commit is contained in:
Vitor Pamplona
2026-06-19 19:59:25 -04:00
committed by GitHub
3 changed files with 109 additions and 2 deletions
@@ -321,6 +321,34 @@ class UrlParserTest {
Urls(withScheme = setOf("http://[2a01:5cc0:1:2::4]")),
)
@Test
fun testUrlWithBalancedParenthesis() =
test(
"https://en.wikipedia.org/wiki/Bitcoin_(disambiguation)",
Urls(withScheme = setOf("https://en.wikipedia.org/wiki/Bitcoin_(disambiguation)")),
)
@Test
fun testUrlWithCommaAndBalancedParenthesis() =
test(
"https://memory-alpha.fandom.com/wiki/Scorpion,_Part_II_(episode)",
Urls(withScheme = setOf("https://memory-alpha.fandom.com/wiki/Scorpion,_Part_II_(episode)")),
)
@Test
fun testUrlWithBalancedParenthesisInSentence() =
test(
"Read https://en.wikipedia.org/wiki/Bitcoin_(disambiguation) for context.",
Urls(withScheme = setOf("https://en.wikipedia.org/wiki/Bitcoin_(disambiguation)")),
)
@Test
fun testUrlWrappedInParenthesisDropsCloser() =
test(
"(see https://test.com)",
Urls(withScheme = setOf("https://test.com")),
)
@Test
fun testBlossom() {
val blossom = "blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.satellite.earth"
@@ -649,8 +649,11 @@ class UrlDetector(
// if the url is valid and greater then 0
if (state == ReadEndState.ValidUrl && buffer.isNotEmpty()) {
var url = buffer.toString()
if (url.lastOrNull() in CANNOT_END_URLS_WITH) url = url.dropLast(1)
urlList.add(currentUrlMarker.createUrl(url))
val last = url.lastOrNull()
if (last != null && last in CANNOT_END_URLS_WITH && !url.endsOnBalancedCloser(last)) {
url = url.dropLast(1)
}
if (url.isNotEmpty()) urlList.add(currentUrlMarker.createUrl(url))
}
// clear out the buffer.
@@ -667,6 +670,37 @@ class UrlDetector(
return state == ReadEndState.ValidUrl
}
/**
* Decides whether a trailing closing delimiter ([last]) should be kept as part of the URL.
*
* Closing parenthesis, braces and brackets are common inside real URLs (e.g. Wikipedia's
* `…/Bitcoin_(disambiguation)`), so we keep a trailing closer when the URL also contains its
* matching opener — i.e. the delimiters are balanced. When the closer is unbalanced it is
* almost always wrapping/sentence punctuation (e.g. `(see example.com)` or `http://test.com)`)
* and is stripped. Any opening delimiter or other punctuation is never balanced, so it falls
* through to the normal trailing strip.
*/
private fun String.endsOnBalancedCloser(last: Char): Boolean {
val opener =
when (last) {
')' -> '('
'}' -> '{'
']' -> '['
else -> return false
}
var depth = 0
for (c in this) {
if (c == opener) {
depth++
} else if (c == last) {
depth--
}
}
// depth >= 0 means every closer (including the trailing one) has a matching opener.
return depth >= 0
}
companion object {
val VALID_SCHEMES_NO_SLASHES: List<String> =
listOf(
@@ -694,6 +728,7 @@ class UrlDetector(
'!',
')',
'}',
']',
'(',
'{',
'\u3002',
@@ -711,6 +746,7 @@ class UrlDetector(
':',
')',
'}',
']',
'(',
'{',
'\u3002',
@@ -839,6 +839,49 @@ class UriDetectionTest {
}
}
@Test
fun testBalancedClosingDelimitersAreKept() {
// Wikipedia-style urls whose path legitimately ends in a balanced ")".
runTest(
"https://en.wikipedia.org/wiki/Bitcoin_(disambiguation)",
"https://en.wikipedia.org/wiki/Bitcoin_(disambiguation)",
)
// also exercises a comma without surrounding spaces inside the path.
runTest(
"https://memory-alpha.fandom.com/wiki/Scorpion,_Part_II_(episode)",
"https://memory-alpha.fandom.com/wiki/Scorpion,_Part_II_(episode)",
)
// a balanced closer is kept even when followed by sentence punctuation/words.
runTest(
"See https://en.wikipedia.org/wiki/Bitcoin_(disambiguation).",
"https://en.wikipedia.org/wiki/Bitcoin_(disambiguation)",
)
runTest(
"read https://en.wikipedia.org/wiki/Bitcoin_(disambiguation) now",
"https://en.wikipedia.org/wiki/Bitcoin_(disambiguation)",
)
// balanced braces/brackets inside the path are kept as well.
runTest("https://example.com/a[b]c", "https://example.com/a[b]c")
runTest("https://example.com/a{b}c", "https://example.com/a{b}c")
// commas without surrounding spaces stay part of the url.
runTest("https://example.com/a,b,c", "https://example.com/a,b,c")
}
@Test
fun testUnbalancedClosingDelimitersAreStripped() {
// a closer that wraps the url (no matching opener inside it) is still removed.
runTest("(see example.com)", "example.com")
runTest("look [example.com]", "example.com")
runTest("note {example.com}", "example.com")
runTest("https://example.com/path)", "https://example.com/path")
// a trailing comma is sentence punctuation and is still removed.
runTest("visit example.com,", "example.com")
}
private fun runTest(
text: String,
vararg expected: String?,