diff --git a/lib/providers/url-regex-provider.ts b/lib/providers/url-regex-provider.ts index 82dad11..801b8f0 100644 --- a/lib/providers/url-regex-provider.ts +++ b/lib/providers/url-regex-provider.ts @@ -30,13 +30,13 @@ export class UrlRegexProvider implements ILinkProvider { * Excludes file paths (no ./ or ../ or bare /) */ private static readonly URL_REGEX = - /(?:https?:\/\/|mailto:|ftp:\/\/|ssh:\/\/|git:\/\/|tel:|magnet:|gemini:\/\/|gopher:\/\/|news:)[\w\-.~:\/?#@!$&*+,;=%]+/gi; + /(?:https?:\/\/|mailto:|ftp:\/\/|ssh:\/\/|git:\/\/|tel:|magnet:|gemini:\/\/|gopher:\/\/|news:)[\w\-.~:\/?#@!$&*+,;=%()]+/gi; /** * Characters to strip from end of URLs * Common punctuation that's unlikely to be part of the URL */ - private static readonly TRAILING_PUNCTUATION = /[.,;!?)\]]+$/; + private static readonly TRAILING_PUNCTUATION = /[.,;!?\]]+$/; constructor(private terminal: ITerminalForUrlProvider) {} @@ -72,6 +72,18 @@ export class UrlRegexProvider implements ILinkProvider { endX = startX + url.length - 1; } + // Strip unbalanced trailing parentheses + while (url.endsWith(')')) { + const open = url.split('(').length - 1; + const close = url.split(')').length - 1; + if (close > open) { + url = url.slice(0, -1); + endX--; + } else { + break; + } + } + // Skip if URL is too short (e.g., just "http://") if (url.length > 8) { links.push({ diff --git a/lib/url-detection.test.ts b/lib/url-detection.test.ts index f31dfc2..8a620f6 100644 --- a/lib/url-detection.test.ts +++ b/lib/url-detection.test.ts @@ -178,6 +178,34 @@ describe('URL Detection', () => { expect(links?.[0].text).toBe('tel:+1234567890'); }); + test('detects URLs with balanced parentheses (Wikipedia)', async () => { + const links = await getLinks('https://en.wikipedia.org/wiki/Rust_(programming_language)'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toBe('https://en.wikipedia.org/wiki/Rust_(programming_language)'); + }); + + test('strips unbalanced trailing paren from wrapped URL', async () => { + const links = await getLinks('(see https://en.wikipedia.org/wiki/Rust_(programming_language))'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toBe('https://en.wikipedia.org/wiki/Rust_(programming_language)'); + }); + + test('handles URL with multiple parenthesized path segments', async () => { + const links = await getLinks('https://example.com/a_(b)/c_(d)'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toBe('https://example.com/a_(b)/c_(d)'); + }); + + test('handles URL with nested parentheses', async () => { + const links = await getLinks('https://example.com/foo_(bar_(baz))'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toBe('https://example.com/foo_(bar_(baz))'); + }); + test('detects magnet: URLs', async () => { const links = await getLinks('Download magnet:?xt=urn:btih:abc123'); expect(links).toBeDefined();