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 9bb6607..8b83c26 100644 --- a/lib/src/url.dart +++ b/lib/src/url.dart @@ -1,15 +1,45 @@ import 'package:linkify/linkify.dart'; +import 'package:linkify/src/email_matcher.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-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//="'`]*))''', - 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( @@ -17,6 +47,12 @@ final _protocolIdentifierRegex = RegExp( caseSensitive: false, ); +const _openingBracketByClosingBracket = { + ')': '(', + ']': '[', + '}': '{', +}; + class UrlLinkifier extends Linkifier { const UrlLinkifier(); @@ -27,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)!)); @@ -42,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; @@ -74,10 +145,6 @@ class UrlLinkifier extends Linkifier { } else { list.add(UrlElement(originalUrl, null, originText)); } - - if (end != null) { - list.add(TextElement(end)); - } } if (text.isNotEmpty) { @@ -93,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 b9c2ccb..86aa89b 100644 --- a/test/linkify_test.dart +++ b/test/linkify_test.dart @@ -131,6 +131,140 @@ 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('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.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('.')], + ); + + 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( + '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')], + ); + + expectListEqual( + linkify('https://user:password@example.com', options: options), + [ + UrlElement( + 'https://user:password@example.com', + 'user:password@example.com', + ), + ], + ); + + 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", () { expectListEqual( linkify("person@example.com at https://google.com", linkifiers: []), @@ -146,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( @@ -193,7 +371,310 @@ void main() { ); }); - test('Parses ending period', () { + 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..'), 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', + 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'), + ], + ); + }); + + 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')], + ); + + 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', () { + 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')], + ); + + 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', () { + 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 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."), [ @@ -201,6 +682,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.', () { @@ -313,4 +830,193 @@ 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('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( + '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('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)'), + [ + 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(']'), + ], + ); + }); }