From 4cf5fb878269f86b53dae5f9a5efa6ab4df4e815 Mon Sep 17 00:00:00 2001 From: floMars Date: Tue, 30 Sep 2025 15:09:21 +0200 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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/8] 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/8] 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(']'), + ], + ); + }); } From 9f3f27500e2790f133ff4beaef9011aee31c63a4 Mon Sep 17 00:00:00 2001 From: florian Date: Thu, 16 Jul 2026 10:10:11 +0200 Subject: [PATCH 7/8] Parse emails correctly with loose URLs --- lib/src/url.dart | 11 ++++++- test/linkify_test.dart | 70 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/lib/src/url.dart b/lib/src/url.dart index 4de7bea..3d61e2a 100644 --- a/lib/src/url.dart +++ b/lib/src/url.dart @@ -7,7 +7,16 @@ final _urlRegex = RegExp( ); final _looseUrlRegex = RegExp( - 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\)\]\}\.,;:!?])', + r'^((?:.*?)(?:^|[^\w@.%+-]|\.\.))' + r'((?:(?:https?:\/\/)(?:[\w.%+-]+(?::[\w.%+-]+)?@)?)?' + r'(?:localhost(?::\d{2,5})(?:[\/?#][^\s<>\x22\x27]*' + r'[^\s<>\x22\x27\)\]\}\.,;:!?])?' + r'|(?:\[(?:[0-9a-f:.]+)\]|(?:\d{1,3}\.){3}\d{1,3}' + r'|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+' + r'(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59}))(?::\d{2,5})?' + r'(?:[\/?#][^\s<>\x22\x27]*' + r'[^\s<>\x22\x27\)\]\}\.\,;:!?])?))' + r'(?=$|[\s<>\x22\x27\)\]\}\.,;:!?])', caseSensitive: false, dotAll: true, ); diff --git a/test/linkify_test.dart b/test/linkify_test.dart index 845e2cc..794a95c 100644 --- a/test/linkify_test.dart +++ b/test/linkify_test.dart @@ -131,6 +131,76 @@ void main() { ); }); + test('Parses emails with loose URL detection', () { + const options = LinkifyOptions( + looseUrl: true, + defaultToHttps: true, + ); + + expectListEqual( + linkify('person@example.com', options: options), + [EmailElement('person@example.com')], + ); + + expectListEqual( + linkify( + 'person@example.com', + options: options, + linkifiers: [UrlLinkifier()], + ), + [TextElement('person@example.com')], + ); + + expectListEqual( + linkify('mailto:person@example.com', options: options), + [EmailElement('person@example.com')], + ); + + expectListEqual( + linkify('person+tag@example.travel', options: options), + [EmailElement('person+tag@example.travel')], + ); + + expectListEqual( + linkify('person@mail.example.com', options: options), + [EmailElement('person@mail.example.com')], + ); + + expectListEqual( + linkify('person@example.com.', options: options), + [EmailElement('person@example.com'), TextElement('.')], + ); + + expectListEqual( + linkify( + 'Email person@example.com and visit example.com.', + options: options, + ), + [ + TextElement('Email '), + EmailElement('person@example.com'), + TextElement(' and visit '), + UrlElement('https://example.com', 'example.com'), + TextElement('.'), + ], + ); + + expectListEqual( + linkify('https://user@example.com', options: options), + [UrlElement('https://user@example.com', 'user@example.com')], + ); + + expectListEqual( + linkify('https://user:password@example.com', options: options), + [ + UrlElement( + 'https://user:password@example.com', + 'user:password@example.com', + ), + ], + ); + }); + test("Doesn't parses email and link with no linkifiers", () { expectListEqual( linkify("person@example.com at https://google.com", linkifiers: []), From f2f42b06f84dbbd03fb7c3741f7fbacbe5459a4b Mon Sep 17 00:00:00 2001 From: florian Date: Thu, 16 Jul 2026 11:48:34 +0200 Subject: [PATCH 8/8] Harden email and URL parsing --- lib/src/email.dart | 19 +-- lib/src/email_matcher.dart | 18 +++ lib/src/url.dart | 187 ++++++++++++++++++++---- test/linkify_test.dart | 288 +++++++++++++++++++++++++++++++++++++ 4 files changed, 473 insertions(+), 39 deletions(-) create mode 100644 lib/src/email_matcher.dart diff --git a/lib/src/email.dart b/lib/src/email.dart index 9471320..c1ed748 100644 --- a/lib/src/email.dart +++ b/lib/src/email.dart @@ -1,10 +1,5 @@ import 'package:linkify/linkify.dart'; - -final _emailRegex = RegExp( - r'^(.*?)((mailto:)?[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z][A-Z]+)', - caseSensitive: false, - dotAll: true, -); +import 'package:linkify/src/email_matcher.dart'; class EmailLinkifier extends Linkifier { const EmailLinkifier(); @@ -15,22 +10,20 @@ class EmailLinkifier extends Linkifier { for (var element in elements) { if (element is TextElement) { - final match = _emailRegex.firstMatch(element.text); + final match = emailRegex.firstMatch(element.text); if (match == null) { list.add(element); } else { final text = element.text.replaceFirst(match.group(0)!, ''); - if (match.group(1)?.isNotEmpty == true) { - list.add(TextElement(match.group(1)!)); + if (match.group(emailPrefixGroup)?.isNotEmpty == true) { + list.add(TextElement(match.group(emailPrefixGroup)!)); } - if (match.group(2)?.isNotEmpty == true) { + if (match.group(emailElementGroup)?.isNotEmpty == true) { // Always humanize emails - list.add(EmailElement( - match.group(2)!.replaceFirst(RegExp(r'mailto:'), ''), - )); + list.add(EmailElement(match.group(emailAddressGroup)!)); } if (text.isNotEmpty) { diff --git a/lib/src/email_matcher.dart b/lib/src/email_matcher.dart new file mode 100644 index 0000000..39e6579 --- /dev/null +++ b/lib/src/email_matcher.dart @@ -0,0 +1,18 @@ +const emailPrefixGroup = 1; +const emailElementGroup = 2; +const emailAddressGroup = 4; + +const emailLocalPartCharacterClass = r'A-Z0-9._%+\-'; +const emailTokenCharacterClass = '$emailLocalPartCharacterClass@'; + +const domainNamePattern = r'(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+' + r'(?:[A-Z]{2,63}|xn--[A-Z0-9-]{2,59})'; + +final emailRegex = RegExp( + r'^(.*?)((mailto:)?(' + '[$emailLocalPartCharacterClass]+@' + '$domainNamePattern' + r'))(?![A-Z0-9_-]|\.[A-Z0-9])', + caseSensitive: false, + dotAll: true, +); diff --git a/lib/src/url.dart b/lib/src/url.dart index 3d61e2a..8b83c26 100644 --- a/lib/src/url.dart +++ b/lib/src/url.dart @@ -1,24 +1,45 @@ import 'package:linkify/linkify.dart'; +import 'package:linkify/src/email_matcher.dart'; final _urlRegex = RegExp( - r'^(.*?)((?:https?:\/\/|www\.)[^\s<>\x22\x27\)\]\}]*[^\s<>\x22\x27\)\]\}\.,;:!?])(?=$|[\s<>\x22\x27\)\]\}\.,;:!?])', + r'^(.*?)((?:https?:\/\/|www\.)[^\s<>\x22\x27]*[^\s<>\x22\x27\.,;:!?]\.?)(?=$|[\s<>\x22\x27\)\]\}\.,;:!?])', caseSensitive: false, dotAll: true, ); -final _looseUrlRegex = RegExp( - r'^((?:.*?)(?:^|[^\w@.%+-]|\.\.))' - r'((?:(?:https?:\/\/)(?:[\w.%+-]+(?::[\w.%+-]+)?@)?)?' - r'(?:localhost(?::\d{2,5})(?:[\/?#][^\s<>\x22\x27]*' - r'[^\s<>\x22\x27\)\]\}\.,;:!?])?' - r'|(?:\[(?:[0-9a-f:.]+)\]|(?:\d{1,3}\.){3}\d{1,3}' - r'|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+' - r'(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59}))(?::\d{2,5})?' - r'(?:[\/?#][^\s<>\x22\x27]*' - r'[^\s<>\x22\x27\)\]\}\.\,;:!?])?))' - r'(?=$|[\s<>\x22\x27\)\]\}\.,;:!?])', - caseSensitive: false, - dotAll: true, +const _ordinaryLooseUrlPrefixPattern = + '(?:.*?)(?:^|[^$emailTokenCharacterClass])'; +const _consecutivePeriodsLooseUrlPrefixPattern = + '(?:.*?[^$emailTokenCharacterClass])?' + '[$emailLocalPartCharacterClass]*\\.\\.'; + +const _urlSuffixPattern = r'(?:[\/?#][^\s<>\x22\x27]*' + r'[^\s<>\x22\x27\.,;:!?]|\/)?'; + +const _protocolWithOptionalUserInfoPattern = + r"(?:https?:\/\/)(?:[A-Z0-9._~!$&'()*+,;=:%\-]+@)?"; +const _localhostWithOptionalPortPattern = r'localhost(?::\d{1,5})?'; +const _localhostWithPortPattern = r'localhost(?::\d{1,5})'; +const _networkHostPattern = r'(?:\[(?:[0-9a-f:.]+)\]|(?:\d{1,3}\.){3}\d{1,3}|' + '$domainNamePattern' + r')(?::\d{1,5})?'; + +const _looseUrlCandidatePattern = '(?:' + '$_protocolWithOptionalUserInfoPattern' + '(?:$_localhostWithOptionalPortPattern|$_networkHostPattern)' + '|' + '$_localhostWithPortPattern' + '|' + '$_networkHostPattern' + ')' + '$_urlSuffixPattern' + r'\.?'; + +final _ordinaryLooseUrlRegex = _createLooseUrlRegex( + _ordinaryLooseUrlPrefixPattern, +); +final _consecutivePeriodsLooseUrlRegex = _createLooseUrlRegex( + _consecutivePeriodsLooseUrlPrefixPattern, ); final _protocolIdentifierRegex = RegExp( @@ -26,6 +47,12 @@ final _protocolIdentifierRegex = RegExp( caseSensitive: false, ); +const _openingBracketByClosingBracket = { + ')': '(', + ']': '[', + '}': '{', +}; + class UrlLinkifier extends Linkifier { const UrlLinkifier(); @@ -36,13 +63,15 @@ class UrlLinkifier extends Linkifier { for (var element in elements) { if (element is TextElement) { var match = options.looseUrl - ? _looseUrlRegex.firstMatch(element.text) + ? _firstLooseUrlMatch(element.text) : _urlRegex.firstMatch(element.text); if (match == null) { list.add(element); } else { - final text = element.text.replaceFirst(match.group(0)!, ''); + final remainingTextAfterMatch = + element.text.replaceFirst(match.group(0)!, ''); + var text = remainingTextAfterMatch; if (match.group(1)?.isNotEmpty == true) { list.add(TextElement(match.group(1)!)); @@ -51,13 +80,46 @@ class UrlLinkifier extends Linkifier { if (match.group(2)?.isNotEmpty == true) { var originalUrl = match.group(2)!; var originText = originalUrl; - String? end; - if ((options.excludeLastPeriod) && - originalUrl[originalUrl.length - 1] == ".") { - end = "."; - originText = originText.substring(0, originText.length - 1); - originalUrl = originalUrl.substring(0, originalUrl.length - 1); + while (true) { + final wrapperStart = _trailingWrapperStart(originalUrl); + if (wrapperStart != null) { + text = originalUrl.substring(wrapperStart) + text; + originText = originText.substring(0, wrapperStart); + originalUrl = originalUrl.substring(0, wrapperStart); + continue; + } + + if (options.excludeLastPeriod && originalUrl.endsWith('.')) { + text = '.$text'; + originText = originText.substring(0, originText.length - 1); + originalUrl = originalUrl.substring(0, originalUrl.length - 1); + continue; + } + + break; + } + + if (!_hasUrlAuthority(originalUrl)) { + if (match.group(1)?.isNotEmpty == true) { + list.removeLast(); + } + final parsedRemaining = remainingTextAfterMatch.isEmpty + ? [] + : parse([TextElement(remainingTextAfterMatch)], options); + if (parsedRemaining.isNotEmpty && + parsedRemaining.first is TextElement) { + list.add( + TextElement( + match.group(0)! + parsedRemaining.first.text, + ), + ); + list.addAll(parsedRemaining.skip(1)); + } else { + list.add(TextElement(match.group(0)!)); + list.addAll(parsedRemaining); + } + continue; } var url = originalUrl; @@ -83,10 +145,6 @@ class UrlLinkifier extends Linkifier { } else { list.add(UrlElement(originalUrl, null, originText)); } - - if (end != null) { - list.add(TextElement(end)); - } } if (text.isNotEmpty) { @@ -102,6 +160,83 @@ class UrlLinkifier extends Linkifier { } } +bool _endsWithUnmatchedClosingBracket(String value) { + final closingBracket = value[value.length - 1]; + final openingBracket = _openingBracketByClosingBracket[closingBracket]; + if (openingBracket == null) { + return false; + } + + return _countCharacter(value, closingBracket) > + _countCharacter(value, openingBracket); +} + +int? _trailingWrapperStart(String value) { + var bracketIndex = value.length - 1; + while (bracketIndex >= 0 && value[bracketIndex] == '.') { + bracketIndex--; + } + + if (bracketIndex < 0) { + return null; + } + + final valueThroughBracket = value.substring(0, bracketIndex + 1); + return _endsWithUnmatchedClosingBracket(valueThroughBracket) + ? bracketIndex + : null; +} + +int _countCharacter(String value, String character) { + var count = 0; + for (var index = 0; index < value.length; index++) { + if (value[index] == character) { + count++; + } + } + return count; +} + +bool _hasUrlAuthority(String value) { + final valueWithoutLastPeriod = + value.endsWith('.') ? value.substring(0, value.length - 1) : value; + + if (valueWithoutLastPeriod.toLowerCase() == 'www') { + return false; + } + + final normalizedValue = + valueWithoutLastPeriod.startsWith(_protocolIdentifierRegex) + ? valueWithoutLastPeriod + : 'http://$valueWithoutLastPeriod'; + return Uri.tryParse(normalizedValue)?.host.isNotEmpty == true; +} + +RegExp _createLooseUrlRegex(String prefixPattern) => RegExp( + '^($prefixPattern)($_looseUrlCandidatePattern)' + r'(?=$|[\s<>\x22\x27\)\]\}\.,;:!?])', + caseSensitive: false, + dotAll: true, + ); + +RegExpMatch? _firstLooseUrlMatch(String text) { + final ordinaryMatch = _ordinaryLooseUrlRegex.firstMatch(text); + final consecutivePeriodsMatch = + _consecutivePeriodsLooseUrlRegex.firstMatch(text); + + if (ordinaryMatch == null) { + return consecutivePeriodsMatch; + } + if (consecutivePeriodsMatch == null) { + return ordinaryMatch; + } + + return ordinaryMatch.group(1)!.length <= + consecutivePeriodsMatch.group(1)!.length + ? ordinaryMatch + : consecutivePeriodsMatch; +} + /// Represents an element containing a link class UrlElement extends LinkableElement { UrlElement(String url, [String? text, String? originText]) diff --git a/test/linkify_test.dart b/test/linkify_test.dart index 794a95c..86aa89b 100644 --- a/test/linkify_test.dart +++ b/test/linkify_test.dart @@ -156,6 +156,11 @@ void main() { [EmailElement('person@example.com')], ); + expectListEqual( + linkify('MAILTO:Person@Example.com', options: options), + [EmailElement('Person@Example.com')], + ); + expectListEqual( linkify('person+tag@example.travel', options: options), [EmailElement('person+tag@example.travel')], @@ -166,6 +171,20 @@ void main() { [EmailElement('person@mail.example.com')], ); + expectListEqual( + linkify('person@example.xn--p1ai', options: options), + [EmailElement('person@example.xn--p1ai')], + ); + + expectListEqual( + linkify( + 'person@example.xn--p1ai', + options: options, + linkifiers: [UrlLinkifier()], + ), + [TextElement('person@example.xn--p1ai')], + ); + expectListEqual( linkify('person@example.com.', options: options), [EmailElement('person@example.com'), TextElement('.')], @@ -185,6 +204,41 @@ void main() { ], ); + expectListEqual( + linkify( + 'person@example.com and example.com', + options: options, + linkifiers: [UrlLinkifier()], + ), + [ + TextElement('person@example.com and '), + UrlElement('https://example.com', 'example.com'), + ], + ); + + expectListEqual( + linkify( + 'foo_bar.com @bar.com', + options: options, + linkifiers: [UrlLinkifier()], + ), + [TextElement('foo_bar.com @bar.com')], + ); + + expectListEqual( + linkify('foo@bar..example.com', options: options), + [TextElement('foo@bar..example.com')], + ); + + expectListEqual( + linkify( + 'mailto:foo@bar..example.com', + options: options, + linkifiers: [UrlLinkifier()], + ), + [TextElement('mailto:foo@bar..example.com')], + ); + expectListEqual( linkify('https://user@example.com', options: options), [UrlElement('https://user@example.com', 'user@example.com')], @@ -199,6 +253,16 @@ void main() { ), ], ); + + expectListEqual( + linkify('https://user:pa:ss@example.com/path', options: options), + [ + UrlElement( + 'https://user:pa:ss@example.com/path', + 'user:pa:ss@example.com/path', + ), + ], + ); }); test("Doesn't parses email and link with no linkifiers", () { @@ -216,6 +280,50 @@ void main() { [UrlElement("http://example.com/test", "example.com/test")], ); + expectListEqual( + linkify('example.com/', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://example.com/', 'example.com/')], + ); + + expectListEqual( + linkify( + 'https://example.com/', + options: LinkifyOptions(looseUrl: true), + ), + [UrlElement('https://example.com/', 'example.com/')], + ); + + expectListEqual( + linkify('example.com/?x=1', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://example.com/?x=1', 'example.com/?x=1')], + ); + + expectListEqual( + linkify( + 'https://example.com/?x=1', + options: LinkifyOptions(looseUrl: true), + ), + [UrlElement('https://example.com/?x=1', 'example.com/?x=1')], + ); + + expectListEqual( + linkify('example.com?', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://example.com', 'example.com'), TextElement('?')], + ); + + expectListEqual( + linkify( + 'https://example.com?', + options: LinkifyOptions(looseUrl: true), + ), + [UrlElement('https://example.com', 'example.com'), TextElement('?')], + ); + + expectListEqual( + linkify('example.com#', options: LinkifyOptions(looseUrl: true)), + [TextElement('example.com#')], + ); + expectListEqual( linkify("www.example.com", options: LinkifyOptions( @@ -279,6 +387,19 @@ void main() { [TextElement('test..'), UrlElement('http://example.com', 'example.com')], ); + expectListEqual( + linkify( + 'test..example.com and next.com', + options: LinkifyOptions(looseUrl: true), + ), + [ + TextElement('test..'), + UrlElement('http://example.com', 'example.com'), + TextElement(' and '), + UrlElement('http://next.com', 'next.com'), + ], + ); + expectListEqual( linkify( '....and i am a sentence', @@ -358,6 +479,44 @@ void main() { linkify('localhost:3000', options: LinkifyOptions(looseUrl: true)), [UrlElement('http://localhost:3000', 'localhost:3000')], ); + + expectListEqual( + linkify('http://localhost', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://localhost', 'localhost')], + ); + + expectListEqual( + linkify('https://localhost/', options: LinkifyOptions(looseUrl: true)), + [UrlElement('https://localhost/', 'localhost/')], + ); + + expectListEqual( + linkify( + 'http://user@localhost', + options: LinkifyOptions(looseUrl: true), + ), + [UrlElement('http://user@localhost', 'user@localhost')], + ); + }); + + test('Includes the last period when requested', () { + const options = LinkifyOptions(excludeLastPeriod: false); + + expectListEqual( + linkify('https://example.com.', options: options), + [UrlElement('https://example.com.', 'example.com.')], + ); + + expectListEqual( + linkify( + 'example.com.', + options: LinkifyOptions( + looseUrl: true, + excludeLastPeriod: false, + ), + ), + [UrlElement('http://example.com.', 'example.com.')], + ); }); test('Parses URLs with ports', () { @@ -380,6 +539,32 @@ void main() { linkify('example.com:8080', options: LinkifyOptions(looseUrl: true)), [UrlElement('http://example.com:8080', 'example.com:8080')], ); + + expectListEqual( + linkify('example.com:8', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://example.com:8', 'example.com:8')], + ); + + expectListEqual( + linkify( + 'https://example.com:8', + options: LinkifyOptions(looseUrl: true), + ), + [UrlElement('https://example.com:8', 'example.com:8')], + ); + + expectListEqual( + linkify('localhost:8', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://localhost:8', 'localhost:8')], + ); + + expectListEqual( + linkify( + 'http://localhost:8', + options: LinkifyOptions(looseUrl: true), + ), + [UrlElement('http://localhost:8', 'localhost:8')], + ); }); test('Parses IP address URLs', () { @@ -687,6 +872,44 @@ void main() { ); }); + test('Does not create URLs without a host after trimming wrappers', () { + expectListEqual( + linkify('(https://)'), + [TextElement('(https://)')], + ); + + expectListEqual( + linkify('(www.)'), + [TextElement('(www.)')], + ); + + expectListEqual( + linkify('(https://) and https://example.com'), + [ + TextElement('(https://) and '), + UrlElement('https://example.com', 'example.com'), + ], + ); + + const keepLastPeriod = LinkifyOptions(excludeLastPeriod: false); + + expectListEqual( + linkify('www.', options: keepLastPeriod), + [TextElement('www.')], + ); + + expectListEqual( + linkify( + '(https://.) and https://example.com', + options: keepLastPeriod, + ), + [ + TextElement('(https://.) and '), + UrlElement('https://example.com', 'example.com'), + ], + ); + }); + test('Excludes wrapping brackets from loose URLs', () { expectListEqual( linkify( @@ -713,6 +936,71 @@ void main() { ); }); + test('Preserves balanced brackets inside URLs', () { + expectListEqual( + linkify('https://en.wikipedia.org/wiki/Function_(mathematics)'), + [ + UrlElement( + 'https://en.wikipedia.org/wiki/Function_(mathematics)', + 'en.wikipedia.org/wiki/Function_(mathematics)', + ), + ], + ); + + expectListEqual( + linkify( + 'example.com/a_(b)_c', + options: LinkifyOptions(looseUrl: true), + ), + [ + UrlElement( + 'http://example.com/a_(b)_c', + 'example.com/a_(b)_c', + ), + ], + ); + }); + + test('Excludes wrapping brackets when retaining URL periods', () { + const options = LinkifyOptions(excludeLastPeriod: false); + + expectListEqual( + linkify('(https://example.com/a_(b)).', options: options), + [ + TextElement('('), + UrlElement( + 'https://example.com/a_(b)', + 'example.com/a_(b)', + ), + TextElement(').'), + ], + ); + + expectListEqual( + linkify('[https://example.com].', options: options), + [ + TextElement('['), + UrlElement('https://example.com', 'example.com'), + TextElement('].'), + ], + ); + + expectListEqual( + linkify( + '{example.com}.', + options: LinkifyOptions( + looseUrl: true, + excludeLastPeriod: false, + ), + ), + [ + TextElement('{'), + UrlElement('http://example.com', 'example.com'), + TextElement('}.'), + ], + ); + }); + test('Does not exclude non-wrapping closing brackets', () { expectListEqual( linkify('https://example.com/path)'),