From 79f9dbff9f044435f6c71ee0f4f303221209ab76 Mon Sep 17 00:00:00 2001 From: Chirag Chandrashekhar Date: Mon, 23 Mar 2026 20:42:56 +0530 Subject: [PATCH] fix: handle nested parentheses in URL link detection (#7406) The LinkifyIt library was truncating URLs containing nested parentheses, such as Kibana/RISON formatted links. For example, a URL like: https://example.com/?_g=(filters:!(),time:(from:now))&_a=(data) would be cut off at the first balanced parenthesis, losing the &_a=... portion. Added extendUrlWithBalancedParentheses helper function that: - Counts unbalanced opening parentheses in the detected URL - Extends the URL to include closing parens and following content - Stops at URL terminators (whitespace, quotes, angle brackets) - Stops if parentheses would become over-balanced (more closing than opening) Fixes #7402 Co-authored-by: Chirag Chandrashekhar --- .../src/utils/codemirror/linkAware.js | 33 +++++++++++++++++-- .../src/utils/codemirror/linkAware.spec.js | 33 ++++++++++++++++++- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/bruno-app/src/utils/codemirror/linkAware.js b/packages/bruno-app/src/utils/codemirror/linkAware.js index 501cfd7b1..628b9d4dc 100644 --- a/packages/bruno-app/src/utils/codemirror/linkAware.js +++ b/packages/bruno-app/src/utils/codemirror/linkAware.js @@ -1,6 +1,32 @@ import LinkifyIt from 'linkify-it'; import { isMacOS } from 'utils/common/platform'; import { debounce } from 'lodash'; + +const URL_TERMINATORS = /[\s"<>\\`]/; +const VALID_URL_CHARS = /^[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]/; + +function extendUrlWithBalancedParentheses(url, line, endIndex) { + let openParens = 0; + for (const char of url) { + if (char === '(') openParens++; + else if (char === ')') openParens--; + } + if (openParens <= 0) return { url, lastIndex: endIndex }; + + let extendedUrl = url; + let i = endIndex; + while (i < line.length) { + const char = line[i]; + if (URL_TERMINATORS.test(char) || !VALID_URL_CHARS.test(char)) break; + if (char === '(') openParens++; + else if (char === ')') openParens--; + if (openParens < 0) break; + extendedUrl += char; + i++; + } + return { url: extendedUrl, lastIndex: i }; +} + /** * Gets the visible line range using scroll info and lineAtHeight * @param {Object} editor - The CodeMirror editor instance @@ -71,14 +97,15 @@ function markUrls(editor, linkify, linkClass, linkHint) { ); if (isInVariable) return; + const extended = extendUrlWithBalancedParentheses(url, lineContent, lastIndex); try { editor.markText( { line: lineNum, ch: index }, - { line: lineNum, ch: lastIndex }, + { line: lineNum, ch: extended.lastIndex }, { className: linkClass, attributes: { - 'data-url': url, + 'data-url': extended.url, 'title': linkHint } } @@ -250,4 +277,4 @@ function setupLinkAware(editor, options = {}) { }; } -export { setupLinkAware }; +export { setupLinkAware, extendUrlWithBalancedParentheses }; diff --git a/packages/bruno-app/src/utils/codemirror/linkAware.spec.js b/packages/bruno-app/src/utils/codemirror/linkAware.spec.js index 9af9c77b6..5ec9a91fb 100644 --- a/packages/bruno-app/src/utils/codemirror/linkAware.spec.js +++ b/packages/bruno-app/src/utils/codemirror/linkAware.spec.js @@ -1,4 +1,4 @@ -import { setupLinkAware } from './linkAware'; +import { setupLinkAware, extendUrlWithBalancedParentheses } from './linkAware'; import LinkifyIt from 'linkify-it'; import { isMacOS } from 'utils/common/platform'; @@ -611,3 +611,34 @@ describe('setupLinkAware', () => { }); }); }); + +describe('extendUrlWithBalancedParentheses', () => { + it('should not modify URLs with balanced parentheses', () => { + const result = extendUrlWithBalancedParentheses('https://example.com/path?q=(a)', 'https://example.com/path?q=(a) end', 31); + expect(result.url).toBe('https://example.com/path?q=(a)'); + }); + + it('should extend URL to balance nested parentheses', () => { + const url = 'https://example.com?_g=(a:!(),b:(c:d'; + const line = 'https://example.com?_g=(a:!(),b:(c:d))&_a=(e) end'; + const result = extendUrlWithBalancedParentheses(url, line, 36); + expect(result.url).toBe('https://example.com?_g=(a:!(),b:(c:d))&_a=(e)'); + }); + + it('should stop at whitespace', () => { + const result = extendUrlWithBalancedParentheses('https://example.com?q=(a', 'https://example.com?q=(a ) end', 24); + expect(result.url).toBe('https://example.com?q=(a'); + }); + + it('should stop when parentheses would become over-balanced', () => { + const result = extendUrlWithBalancedParentheses('https://example.com', '(see https://example.com) end', 24); + expect(result.url).toBe('https://example.com'); + }); + + it('should handle Kibana/RISON URLs with deeply nested parentheses', () => { + const fullUrl = 'https://example.com/app/discover#/?_g=(filters:!(),refreshInterval:(pause:!t,value:60000),time:(from:now-24h%2Fh,to:now))&_a=(columns:!(TopicName,key),dataSource:(dataViewId:f45f79b9,type:dataView),filters:!(),query:(language:kuery,query:\'%22test%22\'),sort:!(!(Timestamp,asc)))'; + const truncatedUrl = 'https://example.com/app/discover#/?_g=(filters:!(),refreshInterval:(pause:!t,value:60000),time:(from:now-24h%2Fh,to:now)'; + const result = extendUrlWithBalancedParentheses(truncatedUrl, fullUrl, 120); + expect(result.url).toBe(fullUrl); + }); +});