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 <cchirag85@gmail.com>
This commit is contained in:
Chirag Chandrashekhar
2026-03-23 20:42:56 +05:30
committed by GitHub
parent 646c90819d
commit 79f9dbff9f
2 changed files with 62 additions and 4 deletions

View File

@@ -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 };

View File

@@ -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);
});
});