From 4cf5fb878269f86b53dae5f9a5efa6ab4df4e815 Mon Sep 17 00:00:00 2001 From: floMars Date: Tue, 30 Sep 2025 15:09:21 +0200 Subject: [PATCH 1/6] Fix: Prevent consecutive periods from being matched as URLs in looseUrl mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated _looseUrlRegex to ensure periods only appear between valid character groups - Added validation to reject matches when prefix ends with a period - Added test cases to verify consecutive periods are not matched as URLs Fixes issue where patterns like 'awdaw....aw', 'awdaw...wad...wadw', and 'test..example.com' were incorrectly identified as valid URLs when looseUrl option was enabled. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/src/url.dart | 9 ++++++++- test/linkify_test.dart | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/src/url.dart b/lib/src/url.dart index 9bb6607..f883a32 100644 --- a/lib/src/url.dart +++ b/lib/src/url.dart @@ -7,7 +7,7 @@ final _urlRegex = RegExp( ); final _looseUrlRegex = RegExp( - r'''^(.*?)((https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//="'`]*))''', + r'''^(.*?)((https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%_\+~#=]+(\.[-a-zA-Z0-9@:%_\+~#=]+)+\b([-a-zA-Z0-9@:%_\+.~#?&//="'`]*))''', caseSensitive: false, dotAll: true, ); @@ -33,6 +33,13 @@ class UrlLinkifier extends Linkifier { if (match == null) { list.add(element); } else { + // Check if the prefix ends with a period (indicating consecutive periods) + final prefix = match.group(1) ?? ''; + if (options.looseUrl && prefix.endsWith('.')) { + list.add(element); + continue; + } + final text = element.text.replaceFirst(match.group(0)!, ''); if (match.group(1)?.isNotEmpty == true) { diff --git a/test/linkify_test.dart b/test/linkify_test.dart index b9c2ccb..6fa7c58 100644 --- a/test/linkify_test.dart +++ b/test/linkify_test.dart @@ -193,6 +193,23 @@ void main() { ); }); + test('Does not parse invalid URLs with consecutive periods', () { + expectListEqual( + linkify('awdaw....aw', options: LinkifyOptions(looseUrl: true)), + [TextElement('awdaw....aw')], + ); + + expectListEqual( + linkify('awdaw...wad...wadw', options: LinkifyOptions(looseUrl: true)), + [TextElement('awdaw...wad...wadw')], + ); + + expectListEqual( + linkify('test..example.com', options: LinkifyOptions(looseUrl: true)), + [TextElement('test..example.com')], + ); + }); + test('Parses ending period', () { expectListEqual( linkify("https://example.com/test."), From 28d5998b0a0678d6b5438625268f68016617083d Mon Sep 17 00:00:00 2001 From: saibotma Date: Thu, 2 Oct 2025 11:35:18 +0200 Subject: [PATCH 2/6] Adjust the algorithm to correctly parse "example.com" in "test..example.com" as a URL --- lib/src/url.dart | 9 +-------- test/linkify_test.dart | 43 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/lib/src/url.dart b/lib/src/url.dart index f883a32..ccc5067 100644 --- a/lib/src/url.dart +++ b/lib/src/url.dart @@ -7,7 +7,7 @@ final _urlRegex = RegExp( ); final _looseUrlRegex = RegExp( - r'''^(.*?)((https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%_\+~#=]+(\.[-a-zA-Z0-9@:%_\+~#=]+)+\b([-a-zA-Z0-9@:%_\+.~#?&//="'`]*))''', + r'^(.*?)((?:https?:\/\/)?(?:www\.)?(?:[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63})(?:[\/?#][^\s]*)?)', caseSensitive: false, dotAll: true, ); @@ -33,13 +33,6 @@ class UrlLinkifier extends Linkifier { if (match == null) { list.add(element); } else { - // Check if the prefix ends with a period (indicating consecutive periods) - final prefix = match.group(1) ?? ''; - if (options.looseUrl && prefix.endsWith('.')) { - list.add(element); - continue; - } - final text = element.text.replaceFirst(match.group(0)!, ''); if (match.group(1)?.isNotEmpty == true) { diff --git a/test/linkify_test.dart b/test/linkify_test.dart index 6fa7c58..d2ceb55 100644 --- a/test/linkify_test.dart +++ b/test/linkify_test.dart @@ -206,7 +206,48 @@ void main() { expectListEqual( linkify('test..example.com', options: LinkifyOptions(looseUrl: true)), - [TextElement('test..example.com')], + [TextElement('test..'), UrlElement('http://example.com', 'example.com')], + ); + + expectListEqual( + linkify('....and i am a sentence', + options: LinkifyOptions(looseUrl: true)), + [TextElement('....and i am a sentence')], + ); + }); + + test('Parses subdomains correctly', () { + expectListEqual( + linkify('https://subdomain.example.com'), + [UrlElement('https://subdomain.example.com', 'subdomain.example.com')], + ); + + expectListEqual( + linkify('https://api.subdomain.example.com'), + [ + UrlElement( + 'https://api.subdomain.example.com', + 'api.subdomain.example.com', + ) + ], + ); + + expectListEqual( + linkify('subdomain.example.com', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://subdomain.example.com', 'subdomain.example.com')], + ); + + expectListEqual( + linkify('Check out api.subdomain.example.com for more info', + options: LinkifyOptions(looseUrl: true)), + [ + TextElement('Check out '), + UrlElement( + 'http://api.subdomain.example.com', + 'api.subdomain.example.com', + ), + TextElement(' for more info'), + ], ); }); From 395f5073cc8b7ef7c370f146b23744049d022460 Mon Sep 17 00:00:00 2001 From: saibotma Date: Thu, 2 Oct 2025 11:52:50 +0200 Subject: [PATCH 3/6] Add trailing commas --- test/linkify_test.dart | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/linkify_test.dart b/test/linkify_test.dart index d2ceb55..a0cc941 100644 --- a/test/linkify_test.dart +++ b/test/linkify_test.dart @@ -210,8 +210,10 @@ void main() { ); expectListEqual( - linkify('....and i am a sentence', - options: LinkifyOptions(looseUrl: true)), + linkify( + '....and i am a sentence', + options: LinkifyOptions(looseUrl: true), + ), [TextElement('....and i am a sentence')], ); }); @@ -238,8 +240,10 @@ void main() { ); expectListEqual( - linkify('Check out api.subdomain.example.com for more info', - options: LinkifyOptions(looseUrl: true)), + linkify( + 'Check out api.subdomain.example.com for more info', + options: LinkifyOptions(looseUrl: true), + ), [ TextElement('Check out '), UrlElement( From 1c90b9b67c51c042fae2667a103c73d7fbc54518 Mon Sep 17 00:00:00 2001 From: saibotma Date: Thu, 2 Oct 2025 12:43:10 +0200 Subject: [PATCH 4/6] Adjust algorithm to also detect localhost and IP urls, urls with ports and punycode domains --- lib/src/url.dart | 4 +- test/linkify_test.dart | 158 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 159 insertions(+), 3 deletions(-) diff --git a/lib/src/url.dart b/lib/src/url.dart index ccc5067..4de7bea 100644 --- a/lib/src/url.dart +++ b/lib/src/url.dart @@ -1,13 +1,13 @@ import 'package:linkify/linkify.dart'; final _urlRegex = RegExp( - r'^(.*?)((?:https?:\/\/|www\.)[^\s/$.?#].[^\s]*)', + r'^(.*?)((?:https?:\/\/|www\.)[^\s<>\x22\x27\)\]\}]*[^\s<>\x22\x27\)\]\}\.,;:!?])(?=$|[\s<>\x22\x27\)\]\}\.,;:!?])', caseSensitive: false, dotAll: true, ); final _looseUrlRegex = RegExp( - r'^(.*?)((?:https?:\/\/)?(?:www\.)?(?:[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63})(?:[\/?#][^\s]*)?)', + r'^(.*?)((?:https?:\/\/)?(?:localhost(?::\d{2,5})(?:[\/?#][^\s<>\x22\x27]*[^\s<>\x22\x27\)\]\}\.,;:!?])?|(?:[\w.%+-]+(?::[\w.%+-]+)?@)?(?:\[(?:[0-9a-f:.]+)\]|(?:\d{1,3}\.){3}\d{1,3}|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59}))(?::\d{2,5})?(?:[\/?#][^\s<>\x22\x27]*[^\s<>\x22\x27\)\]\}\.\,;:!?])?))(?=$|[\s<>\x22\x27\)\]\}\.,;:!?])', caseSensitive: false, dotAll: true, ); diff --git a/test/linkify_test.dart b/test/linkify_test.dart index a0cc941..641a313 100644 --- a/test/linkify_test.dart +++ b/test/linkify_test.dart @@ -255,7 +255,127 @@ void main() { ); }); - test('Parses ending period', () { + test('Parses localhost URLs', () { + expectListEqual( + linkify('http://localhost'), + [UrlElement('http://localhost', 'localhost')], + ); + + expectListEqual( + linkify('http://localhost:3000'), + [UrlElement('http://localhost:3000', 'localhost:3000')], + ); + + expectListEqual( + linkify('http://localhost:8080/api/test'), + [UrlElement('http://localhost:8080/api/test', 'localhost:8080/api/test')], + ); + + expectListEqual( + linkify('localhost', options: LinkifyOptions(looseUrl: true)), + [TextElement('localhost')], + ); + + expectListEqual( + linkify( + 'Check out localhost for testing', + options: LinkifyOptions(looseUrl: true), + ), + [TextElement('Check out localhost for testing')], + ); + + expectListEqual( + linkify('localhost:3000', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://localhost:3000', 'localhost:3000')], + ); + }); + + test('Parses URLs with ports', () { + expectListEqual( + linkify('https://example.com:8080'), + [UrlElement('https://example.com:8080', 'example.com:8080')], + ); + + expectListEqual( + linkify('https://api.example.com:3000/path'), + [ + UrlElement( + 'https://api.example.com:3000/path', + 'api.example.com:3000/path', + ) + ], + ); + + expectListEqual( + linkify('example.com:8080', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://example.com:8080', 'example.com:8080')], + ); + }); + + test('Parses IP address URLs', () { + expectListEqual( + linkify('http://192.168.1.1'), + [UrlElement('http://192.168.1.1', '192.168.1.1')], + ); + + expectListEqual( + linkify('http://192.168.1.1:8080'), + [UrlElement('http://192.168.1.1:8080', '192.168.1.1:8080')], + ); + + expectListEqual( + linkify('https://10.0.0.1:3000/api'), + [UrlElement('https://10.0.0.1:3000/api', '10.0.0.1:3000/api')], + ); + + expectListEqual( + linkify('192.168.1.1:8080', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://192.168.1.1:8080', '192.168.1.1:8080')], + ); + + expectListEqual( + linkify( + 'Check out 192.168.1.1:8080 for the dashboard', + options: LinkifyOptions(looseUrl: true), + ), + [ + TextElement('Check out '), + UrlElement('http://192.168.1.1:8080', '192.168.1.1:8080'), + TextElement(' for the dashboard'), + ], + ); + }); + + test('Parses punycode domains', () { + // xn--n3h.com is ☃.com (snowman emoji domain) + expectListEqual( + linkify('https://xn--n3h.com'), + [UrlElement('https://xn--n3h.com', 'xn--n3h.com')], + ); + + // xn--bcher-kva.com is bücher.com (books in German) + expectListEqual( + linkify('https://xn--bcher-kva.com'), + [UrlElement('https://xn--bcher-kva.com', 'xn--bcher-kva.com')], + ); + + expectListEqual( + linkify('xn--n3h.com', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://xn--n3h.com', 'xn--n3h.com')], + ); + + expectListEqual( + linkify('Visit xn--bcher-kva.com for more', + options: LinkifyOptions(looseUrl: true)), + [ + TextElement('Visit '), + UrlElement('http://xn--bcher-kva.com', 'xn--bcher-kva.com'), + TextElement(' for more'), + ], + ); + }); + + test('Parses ending period and trailing punctuation', () { expectListEqual( linkify("https://example.com/test."), [ @@ -263,6 +383,42 @@ void main() { TextElement(".") ], ); + + expectListEqual( + linkify('Check out https://example.com!'), + [ + TextElement('Check out '), + UrlElement('https://example.com', 'example.com'), + TextElement('!'), + ], + ); + + expectListEqual( + linkify('Visit https://example.com, then come back.'), + [ + TextElement('Visit '), + UrlElement('https://example.com', 'example.com'), + TextElement(', then come back.'), + ], + ); + + expectListEqual( + linkify('See https://example.com?'), + [ + TextElement('See '), + UrlElement('https://example.com', 'example.com'), + TextElement('?'), + ], + ); + + expectListEqual( + linkify('Go to example.com.', options: LinkifyOptions(looseUrl: true)), + [ + TextElement('Go to '), + UrlElement('http://example.com', 'example.com'), + TextElement('.'), + ], + ); }); test('Parses CR correctly.', () { From e17ee83c59848c2e21014ea9dae6fe5d311945dd Mon Sep 17 00:00:00 2001 From: saibotma Date: Thu, 2 Oct 2025 13:46:11 +0200 Subject: [PATCH 5/6] Test that it parses TLDs with more than four letters correctly --- test/linkify_test.dart | 44 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/test/linkify_test.dart b/test/linkify_test.dart index 641a313..4a2e5c7 100644 --- a/test/linkify_test.dart +++ b/test/linkify_test.dart @@ -375,6 +375,50 @@ void main() { ); }); + test('Parses TLDs with more than 4 letters', () { + expectListEqual( + linkify('https://example.design'), + [UrlElement('https://example.design', 'example.design')], + ); + + expectListEqual( + linkify('https://example.travel'), + [UrlElement('https://example.travel', 'example.travel')], + ); + + expectListEqual( + linkify('https://example.cloud'), + [UrlElement('https://example.cloud', 'example.cloud')], + ); + + expectListEqual( + linkify('example.design', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://example.design', 'example.design')], + ); + + expectListEqual( + linkify('example.travel', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://example.travel', 'example.travel')], + ); + + expectListEqual( + linkify('example.cloud', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://example.cloud', 'example.cloud')], + ); + + expectListEqual( + linkify( + 'Check out example.design for more info', + options: LinkifyOptions(looseUrl: true), + ), + [ + TextElement('Check out '), + UrlElement('http://example.design', 'example.design'), + TextElement(' for more info'), + ], + ); + }); + test('Parses ending period and trailing punctuation', () { expectListEqual( linkify("https://example.com/test."), From e990021f30b8535b462d41a39f37019045ae55f4 Mon Sep 17 00:00:00 2001 From: saibotma Date: Thu, 2 Oct 2025 13:50:39 +0200 Subject: [PATCH 6/6] Test that it correctly parses parenthesis around URLs --- test/linkify_test.dart | 86 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/test/linkify_test.dart b/test/linkify_test.dart index 4a2e5c7..845e2cc 100644 --- a/test/linkify_test.dart +++ b/test/linkify_test.dart @@ -575,4 +575,90 @@ void main() { ], ); }); + + test('Excludes wrapping parentheses from URLs', () { + expectListEqual( + linkify('Some text before (https://github.com/Cretezy/flutter_linkify).'), + [ + TextElement('Some text before ('), + UrlElement( + 'https://github.com/Cretezy/flutter_linkify', + 'github.com/Cretezy/flutter_linkify', + ), + TextElement(').'), + ], + ); + + expectListEqual( + linkify('Check this out (https://example.com)'), + [ + TextElement('Check this out ('), + UrlElement('https://example.com', 'example.com'), + TextElement(')'), + ], + ); + + expectListEqual( + linkify('Link: [https://example.com]'), + [ + TextElement('Link: ['), + UrlElement('https://example.com', 'example.com'), + TextElement(']'), + ], + ); + + expectListEqual( + linkify('Code: {https://example.com}'), + [ + TextElement('Code: {'), + UrlElement('https://example.com', 'example.com'), + TextElement('}'), + ], + ); + }); + + test('Excludes wrapping brackets from loose URLs', () { + expectListEqual( + linkify( + 'Some text before (example.com/path).', + options: LinkifyOptions(looseUrl: true), + ), + [ + TextElement('Some text before ('), + UrlElement('http://example.com/path', 'example.com/path'), + TextElement(').'), + ], + ); + + expectListEqual( + linkify( + 'Check [example.com]', + options: LinkifyOptions(looseUrl: true), + ), + [ + TextElement('Check ['), + UrlElement('http://example.com', 'example.com'), + TextElement(']'), + ], + ); + }); + + test('Does not exclude non-wrapping closing brackets', () { + expectListEqual( + linkify('https://example.com/path)'), + [ + UrlElement('https://example.com/path', 'example.com/path'), + TextElement(')'), + ], + ); + + expectListEqual( + linkify('No opening bracket https://example.com]'), + [ + TextElement('No opening bracket '), + UrlElement('https://example.com', 'example.com'), + TextElement(']'), + ], + ); + }); }