From da336897c236734be0e285f3f6c7b535945ada5c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 23:38:44 +0000 Subject: [PATCH] fix: keep balanced closing delimiters and inline commas in detected URLs The URL detector stripped a single trailing punctuation char unconditionally, which dropped the closing ")" from legitimate URLs such as https://en.wikipedia.org/wiki/Bitcoin_(disambiguation). Make the trailing strip balance-aware: a trailing ")", "}" or "]" is kept when the URL contains its matching opener (balanced), and only stripped when it is unbalanced wrapping/sentence punctuation (e.g. "(see example.com)" or "http://test.com)"). Commas without surrounding spaces were already kept inside paths; this also adds "]" to the begin/end punctuation sets so an unbalanced bracket is handled symmetrically with parens and braces. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01AzZzVcMcuzjSdhD3xqCE87 --- .../commons/richtext/UrlParserTest.kt | 28 ++++++++++++ .../urldetector/detection/UrlDetector.kt | 40 ++++++++++++++++- .../urldetector/detection/UriDetectionTest.kt | 43 +++++++++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt index 7ce6c472d3..f1444b88cc 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt @@ -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" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt index 7915ec9ed8..cf2e8a5f4c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt @@ -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 = listOf( @@ -694,6 +728,7 @@ class UrlDetector( '!', ')', '}', + ']', '(', '{', '\u3002', @@ -711,6 +746,7 @@ class UrlDetector( ':', ')', '}', + ']', '(', '{', '\u3002', diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt index ae161e89cb..76ffc005e8 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt @@ -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?,