diff --git a/README.md b/README.md index b338177..dc78d56 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Like what we're doing? Show your support with a quick star, please! ⭐ Want the same power directly in the browser? Check out the JS version: [SimplifiedRegex](https://github.com/MaestroError/simplified-regex) πŸš€ -Feeling overwhelmed by the documentation? With a ChatGPT Plus subscription, you can streamline your experience by utilizing the [EloquentRegex Assistant](https://chat.openai.com/g/g-CtG1m2bI7-eloquentregex-assistant) GPT πŸ€– +Feeling overwhelmed by the documentation? You can streamline your experience by utilizing the [EloquentRegex Assistant](https://chat.openai.com/g/g-CtG1m2bI7-eloquentregex-assistant) GPT πŸ€– ### Table of Contents @@ -18,10 +18,21 @@ Feeling overwhelmed by the documentation? With a ChatGPT Plus subscription, you - πŸ”‘[Key Features](#key-features) - 🧭[Getting Started](#getting-started) - **[Basic Usage](#basic-usage)** + - ⚑[Actions](#actions) + - [Get](#get) + - [Check](#check) + - [CheckString](#checkstring) + - [Count](#count) + - [Replace](#replace) + - [ToRegex](#toregex) + - [Search](#search) + - [Search benchmark](#search-benchmark) + - [SearchReverse](#searchreverse) + - [Swap](#swap) - πŸ“‘[Ready-to-Use Patterns](#ready-to-use-patterns) - - πŸ› οΈ[Custom Patterns](#custom-patterns) + - πŸ› οΈ[Custom Patterns](#custom-patterns%EF%B8%8F) - πŸ’  [Creating a Custom Pattern](#creating-a-custom-pattern) - - #️⃣[Applying Quantifiers](#applying-quantifiers) + - #️⃣[Applying Quantifiers](#applying-quantifiers%EF%B8%8F⃣) - πŸ’  [Optional Elements](#optional-elements) - πŸ’  [Specifying a Range](#specifying-a-range) - πŸ’  [One or More](#one-or-more) @@ -30,7 +41,7 @@ Feeling overwhelmed by the documentation? With a ChatGPT Plus subscription, you - πŸ’  [Custom Character Sets and Groups](#to-custom-character-sets-and-groups) - πŸ’  [Quantifier Values](#quantifier-values) - **[Advanced usage](#advanced-usage)** - - βš™οΈ[Options](#options) + - βš™οΈ[Options](#options%EF%B8%8F) - πŸ’  [Options as extra assertions](#options-as-extra-assertions) - πŸ’  [Options as filters](#options-as-filters) - πŸ’  [Options list](#options-list) @@ -41,17 +52,17 @@ Feeling overwhelmed by the documentation? With a ChatGPT Plus subscription, you - πŸ’  [Single-Line Mode](#single-line-mode) - πŸ’  [Unicode Character Matching](#unicode-character-matching) - **[Advanced builderPattern methods](#advanced-builderpattern-methods)** - - πŸ—ƒοΈ[Character Sets](#character-sets) + - πŸ—ƒοΈ[Character Sets](#character-sets%EF%B8%8F) - πŸ“¦[Groups](#groups) - πŸ’  [Capturing Groups](#capturing-groups) - πŸ’  [Non-Capturing Groups](#non-capturing-groups) - πŸ’  [Groups with quantifier](#groups-with-quantifier) - ❓[Conditional matching](#conditional-matching) - - βš–οΈ[Pattern alternation (orPattern)](#pattern-alternation-orpattern) + - βš–οΈ[Pattern alternation (orPattern)](#pattern-alternation-orpattern%EF%B8%8F) - 🧩[Raw Methods](#raw-methods) - 🐌[The Lazy Quantifier Method](#the-lazy-quantifier-method) - **[Testing and Debugging Your Regex Patterns](#testing-and-debugging-your-regex-patterns)** -- **[Contributing to EloquentRegex](#contributing-to-eloquenttegex)** +- **[Contributing to EloquentRegex](#contributing-to-eloquentregex)** - **[Support](#support)** - **[Credits](#credits)** - **[Frequently Asked Questions (FAQ)](#frequently-asked-questions-faq)** @@ -90,10 +101,11 @@ EloquentRegex::start("#hello #world This is a #test")->hash()->text()->get(); ## Key FeaturesπŸ”‘ -- **Ready-to-Use Patterns**: Common patterns like emails, URLs, and IP addresses are pre-defined and ready to go. Just a few keystrokes and you're validating. +- **Ready-to-Use Patterns**: Common patterns like emails, URLs, IP addresses and etc. are pre-defined and ready to go. Just a few keystrokes and you're validating. - **Custom Patterns Made Easy**: Build your own regex patterns with an easy-to-use, fluent interface. Say hello to readable regex! -- **Options and Filters**: Tailor your regex operations with options and filters for precision matching. It's like having a regex wizard at your fingertips. -- **Laravel Integration**: Seamlessly integrates with your Laravel projects, leveraging Laravel's elegant syntax and features. +- **Useful actions**: You can perform various actions with your pattern, from simply validating and getting the matches to complex actions like `search` or `replace`. +- **Options and Filters**: Tailor your regex operations with options and filters like `onlyMasterCard`, `maxSpaces`, `validIPv6` and etc. for more precision. +- **Laravel Integration**: Seamlessly integrates with your Laravel projects, leveraging Laravel's elegant syntax and features like collection. _For more details about package and it's inner workings check out [STRUCTURE.md](https://github.com/MaestroError/eloquent-regex/blob/update-documentation-and-add-advanced-usage-section/STRUCTURE.md) file._ @@ -111,7 +123,7 @@ Need to get started quickly? Read the [quick start guide](https://medium.com/@re # Basic Usage -EloquentRegex simplifies regular expressions in Laravel, making it easy to validate data, search text, and extract information. This section introduces the basic usage of EloquentRegex, including leveraging ready-to-use patterns and creating custom patterns. +EloquentRegex simplifies regular expressions in Laravel, making it easy to validate data, search text, and extract information. This section introduces the basic usage of EloquentRegex, including leveraging [ready-to-use](#ready-to-use-patterns) patterns and creating [custom](#custom-patterns%EF%B8%8F) patterns. First of all, you need to include EloquentRegex class. @@ -125,7 +137,307 @@ use Maestroerror\EloquentRegex\EloquentRegex; use Maestroerror\EloquentRegex\Facades\EloquentRegex; ``` -Usage structure is very similar to Laravel's Eloquent ORM, check this out: +## Actions + +Actions are end methods created to finilize your pattern and take some action with it. So they are the main features of the package as well. Let's discuss them one by one and check the examples. + +### Get + +Returns all matches as array/collection. Returns `null` if no matches found. + +_Example with ready-to-use pattern_ + +```php +EloquentRegex::source("Support: support@example.com; Info: info@example.com") + ->email() + ->get(); +// Returns: ["support@example.com", "info@example.com"] +``` + +_Example with custom pattern_ + +```php +EloquentRegex::start("#hello #world This is a #test") + ->hash()->text() + ->get(); +// Returns: ['#hello', '#world', '#test'] +``` + +### Check + +Checks if string exactly matches the pattern from start to end (strict match). + +_Example with ready-to-use pattern_ + +```php +EloquentRegex::source("support@example.com") + ->email()->check(); +// Returns: true +``` + +_Example with custom pattern_ + +```php +EloquentRegex::start("#test") + ->hash()->text() + ->check(); +// Returns: true +``` + +### CheckString + +Checks if string contains any matches of pattern. In case of email pattern, it will return `true` if one or more email is present in the given source string. + +_Example with ready-to-use pattern_ + +```php +EloquentRegex::source("Support: support@example.com; Info: info@example.com") + ->email()->checkString(); +// Returns: true +``` + +_Example with custom pattern_ + +```php +EloquentRegex::start("#hello #world This is a #test") + ->hash()->text() + ->checkString(); +// Returns: true +``` + +### Count + +Counts amount of matches and returns as int. Returns `0` if no matches found. + +_Example with ready-to-use pattern_ + +```php +EloquentRegex::source("Support: support@example.com; Info: info@example.com") + ->email()->count(); +// Returns: 2 +``` + +_Example with custom pattern_ + +```php +EloquentRegex::start("#hello #world This is a #test") + ->hash()->text() + ->count(); +// Returns: 3 +``` + +### Replace + +Replaces found matches in given source string using provided **callback**. + +_Example with ready-to-use pattern_ + +```php +EloquentRegex::source("Support: support@example.com; Info: info@example.com") + ->email() + ->replace(function($foundItem) { + return "" . $foundItem . ""; + }); +// Returns: "Support: support@example.com; Info: info@example.com" +``` + +_Example with custom pattern_ + +```php +EloquentRegex::start("This is a #test") + ->hash()->text() + ->replace(function($foundItem) { + return "" . $foundItem . ""; + }); +// Returns: "This is a #test" +``` + +### ToRegex + +Returns built raw regex as string. If any [options](#options%EF%B8%8F) applied, it will **not be returned** using `toRegex` method. + +_Example with custom pattern_ + +```php +EloquentRegex::builder()->start() + ->textLowercase() + ->atSymbol() + ->textLowercase() + ->dot() + ->textLowercaseRange(2, 4) + ->toRegex(); +// Returns: "[a-z]+@[a-z]+\.[a-z]{2,4}" +``` + +### Search + +Search method searches for **keyword** or **pattern** (including ready-to-use patterns too) in multiline text and returns lines where subject is found. It is especially useful with processing of large files like logs or JSON. + +_Example with keyword search_ + +```php +EloquentRegex::source( + " + Whose woods these are I think I know.\n + His house is in the village though;\n + He will not see me stopping here\n + To watch his woods fill up with snow.\n + \n + The woods are lovely, dark and deep,\n + But I have promises to keep,\n + And miles to go before I sleep,\n + And miles to go before I sleep.\n + " + ) + ->search("woods"); +/* Returns: [ + "Whose woods these are I think I know.", + "To watch his woods fill up with snow.", + "The woods are lovely, dark and deep,", + ] +*/ +``` + +_Example with pattern_ + +```php +EloquentRegex::source( + " + Please contact us via email at info@example.com for more details. + For support inquiries, you can also email us at support@example.com. + Our marketing team is reachable at marketing@example.com for collaborations. + For urgent matters, you can reach out through the phone number provided. + Subscribe to our newsletter to stay updated with the latest news. + Feel free to send feedback directly to our office address. + Any emails sent after 5 PM may be responded to the next business day. + Check the FAQ section for answers to common questions. + Social media channels are also available for quick updates. + We value your input and encourage you to share your thoughts. + " + ) + ->search(function ($pattern) { + $pattern->email(); + }); +/* Returns: +[ + 'Please contact us via email at info@example.com for more details.', + 'For support inquiries, you can also email us at support@example.com.', + 'Our marketing team is reachable at marketing@example.com for collaborations.' +] +*/ +``` + +#### Search benchmark + +_Interesting fact: The shorter keyword is, the faster the search methods work_ + +Check benchmark of `search` for keyword "green" in large JSON file, where each line was JSON object from DB: + +```php +/* +=========================================================== +| ROWS | Find (row count) | File size | Find + decoded | +=========================================================== +| 1000 | 7.6 ms (890) | 5 Mb | 11.5 ms | +| 2500 | 17.25ms (2186) | 14 Mb | 30 ms | +| 5000 | 34.4 ms (4347) | 29 Mb | 62 ms | +| 10K | 67.4 ms (8669) | 58 Mb | 112 ms | +| 20K | 131 ms (17313) | 116 Mb | 251 ms | +=========================================================== +===Keyword:="green"======================================== +*/ +``` + +### SearchReverse + +SearchReverse method searches for **keyword** or **pattern** in multiline text and returns every line which **doesn't contain** subject. It is especially useful while processing large text files like logs or JSON. + +_Example with keyword search_ + +```php +// Find all logs types except INFO +EloquentRegex::source( + " + [2024-12-23 10:00:00] INFO: User logged in.\n + [2024-12-25 10:05:00] ERROR: Unable to connect to database.\n + [2024-12-25 10:10:00] INFO: User updated profile.\n + [2024-12-15 10:15:00] WARNING: Disk space running low.\n + [2024-12-34 10:20:00] ERROR: Timeout while fetching data.\n + " + ) + ->searchReverse("INFO"); +/* Returns: [ + '[2024-12-25 10:05:00] ERROR: Unable to connect to database.', + '[2024-12-15 10:15:00] WARNING: Disk space running low.', + '[2024-12-34 10:20:00] ERROR: Timeout while fetching data.', + ] +*/ +``` + +### Swap + +Swap method allows you to swap any kind of data logically, for example, build new URLs from old ones. It utilizes "named groups" regex feature and can be used with **callback** or **pattern string** (Check the example) + +_Example with pattern string_ + +```php +$builder= EloquentRegex::start("URIs: /container-tbilisi-1585, /container-berlin-1234, /container-tbilisi-2555") + ->slash() // "/" + ->exact("container") // "container" (static part of URI) + ->dash() // "-" + ->namedGroup(function ($pattern) { + return $pattern->text(); + }, "City") // Text between dashes, Grouped & named as "city" + ->dash() // "-" + ->namedGroup(function ($pattern) { + return $pattern->digitsRange(2, 5); + }, "id") // Numbers at end, Grouped & named as "id" + ->end(); // Ends custom pattern to make "swap" method available + +// Using swap with pattern string +// which will swap placeholders like "[ID]" with +// Extracted data for each found match +$builder->swap("/container/[ID]?city=[CITY]"); + +/* Returns: +[ + '/container/1585?city=tbilisi', + '/container/1234?city=berlin', + '/container/2555?city=tbilisi' +] +*/ +``` + +_Example with callback_ + +```php +$builder = EloquentRegex::start("Issues in progress: RI-2142, RI-1234, PO-2555"); +$builder + ->namedGroup(function ($pattern) { + return $pattern->textUppercase(2); + }, "project", 1) // 2 uppercase char named as "project" + ->dash() // "-" + ->namedGroup(function ($pattern) { + return $pattern->digitsRange(2, 4); + }, "issue", 1) // from 2 to 4 digits named as issue + ->end(); + + $results = $result->swap(function ($data) { + return "The issue #" . $data["issue"] . " of project " . $data["project"] ." is in progress"; + }); + +/* Returns: +[ + 'The issue #2142 of project RI is in progress', + 'The issue #1234 of project RI is in progress', + 'The issue #2555 of project PO is in progress' +] +*/ +``` + +## Usage structure + +As you may already found out, the usage structure is similar to Laravel's Eloquent ORM, check this out: ``` [Initiator][Pattern][?Optional][Action] @@ -139,7 +451,7 @@ Let's break it down: EloquentRegex::source($yourString); ``` -- **_Pattern_** Could be method for one of the ready-to-use patterns or your custom pattern (we will talk about custom patterns later). Let's keep the example simple and add url pattern: +- **_Pattern_** Could be method for one of the ready-to-use patterns or your custom pattern (we will talk about custom patterns later). Let's keep the example simple and add `url` pattern: ```php EloquentRegex::source($yourString)->url(); @@ -147,7 +459,7 @@ EloquentRegex::source($yourString)->url(); _Note: **?Optional** methods mostly are the expression flags, we will talk about them in next sections_ -- **_Action_** is the execution method, check the example: +- **_Action_** is the execution methods like `get`, `check` and etc. Check the examples: ```php // get() will return array/collection of URLs if any found in $yourString @@ -288,7 +600,7 @@ Didn't it cover all your needs? Let's take a look to the custom patterns section ## Custom PatternsπŸ› οΈ -For scenarios where predefined patterns do not suffice, EloquentRegex allows you to define custom patterns using the start or customPattern methods as initiator: +For scenarios where predefined patterns do not suffice, EloquentRegex allows you to define custom patterns using the `start` or `customPattern` methods as initiator: ```php EloquentRegex::start($yourString); @@ -320,10 +632,10 @@ _Note: You can use `EloquentRegex::builder()->pattern()` if you need just build Custom pattern builder supports a wide range of character classes and all special chars. Also, `literal` or `exact` method could be used to match exact string you need, or `char` method could be used to match exact character. The full list of pattern builder methods is comming soon. Before that, you can check this files out: -- [Character Classes](https://github.com/MaestroError/eloquent-regex/blob/documentation-and-examples/src/Traits/BuilderPatternTraits/CharacterClassesTrait.php) -- [Special characters](https://github.com/MaestroError/eloquent-regex/blob/documentation-and-examples/src/Traits/BuilderPatternTraits/SpecificCharsTrait.php) -- [Groups](https://github.com/MaestroError/eloquent-regex/blob/documentation-and-examples/src/Traits/BuilderPatternTraits/GroupsTrait.php) -- [Anchors](https://github.com/MaestroError/eloquent-regex/blob/documentation-and-examples/src/Traits/BuilderPatternTraits/AnchorsTrait.php) +- [Character Classes](https://github.com/MaestroError/eloquent-regex/blob/maestro/src/Traits/BuilderPatternTraits/CharacterClassesTrait.php) +- [Special characters](https://github.com/MaestroError/eloquent-regex/blob/maestro/src/Traits/BuilderPatternTraits/SpecificCharsTrait.php) +- [Groups](https://github.com/MaestroError/eloquent-regex/blob/maestro/src/Traits/BuilderPatternTraits/GroupsTrait.php) +- [Anchors](https://github.com/MaestroError/eloquent-regex/blob/maestro/src/Traits/BuilderPatternTraits/AnchorsTrait.php) ## Applying Quantifiers#️⃣ @@ -806,6 +1118,40 @@ $result = EloquentRegex::start("2024-01-30, 2023-02-20") */ ``` +### Named Capturing Groups + +It is same as capturing group but named and is used to group part of a pattern together and capture the matching text for later use with it's name. Note that it returs array/collection with different structure while using with get: + +```php +// Matching a date format with capturing the parts as separated groups +EloquentRegex::start("RI-2142, PO-2555") + ->namedGroup(function ($pattern) { + return $pattern->textUppercase(2); + }, "project", 1) + ->dash() + ->namedGroup(function ($pattern) { + return $pattern->digitsRange(2, 4); + }, "issue", 1) + ->get(); + +/* Returns: +[ + "result" => "RI-2142", + "groups" => [ + "project" => "RI", + "issue" => "2142", + ] +], +[ + "result" => "PO-2555", + "groups" => [ + "project" => "PO", + "issue" => "2555", + ] +] + */ +``` + ### Non-Capturing Groups Non-capturing groups organize patterns logically without capturing separately the matched text. @@ -860,7 +1206,7 @@ Matches digits only if they are preceded by a 'P' ```php // Expected to be true as '3' is preceded by 'P' EloquentRegex::start('P3') -->negativeLookBehind(function($pattern) { +->lookBehind(function($pattern) { $pattern->character('P'); })->digits()->check(); // While using "get()" method, 'P' doesn't appear in matches @@ -1180,15 +1526,23 @@ To stay updated, follow the GitHub repository for the latest changes, releases, ##### To Do +- Fix traits URLs in docs βœ… +- Add replace method βœ… +- Add reverse method (to get everything except matched pattern) - try negative lookBehind or Lookahead with big texts βœ… + - Search and SearchReverse with keyword or pattern, check the searchTest\search.php file βœ… +- Implement usage of named groups: `/(?P\d{4})-(?P\d{2})-(?P\d{2})/` βœ… + - Make available to do thing like this: β€œ/container-[MATERIAL_NAME]-[NUMBER]” β†’ β€œ/konfigurator/materialien?material=[MATERIAL_NAME]” βœ… - Add options for new patterns: - Add `contains` and `notContains` options - usernameLength: Set minimum and maximum length for the username part of the email. - dateFormat, timeFormat: Specify the format of date and time (e.g., MM-DD-YYYY, HH:MM). -- Implement usage of named groups: `/(?P\d{4})-(?P\d{2})-(?P\d{2})/` - Create some tool for debuging the Options - Write documentation: - - Create quick start guide and add in Docs. + + + - Create quick start guide and add in Docs. βœ… + - Add in Basic Usage topic the "Features" docs with both pattern examples βœ… + - Add builderPattern methods list MD file and link from the Docs. - Add options debuging section in docs @@ -1201,3 +1555,4 @@ To stay updated, follow the GitHub repository for the latest changes, releases, - Implement first() method using preg_match instead of preg_match_all - I should be able to make new pattern using BuilderPattern - I should be able to add custom pattern to the existing one using BuilderPattern +- I should be able to add custom option using BuilderPattern, raw regex or regular PHP functions diff --git a/composer.json b/composer.json index ecab9cd..d7458da 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,9 @@ "pestphp/pest-plugin": true } }, + "scripts": { + "test": "./vendor/bin/pest" + }, "extra": { "laravel": { "providers": [ @@ -33,4 +36,4 @@ } } } -} +} \ No newline at end of file diff --git a/gpt-knowledge/EloquentRegexTest.php.txt b/gpt-knowledge/EloquentRegexTest.php.txt new file mode 100644 index 0000000..ec432ac --- /dev/null +++ b/gpt-knowledge/EloquentRegexTest.php.txt @@ -0,0 +1,666 @@ +start() + ->exact("alt=") + ->group(function ($pattern) { + $pattern->doubleQuote()->orPattern(function ($pattern) { + $pattern->singleQuote(); + }); + })->toRegex(); + + expect($regex)->toBe("alt\=(\"|')"); +}); + +it('reproduces hashtag prefix pattern from HSA using wrapper', function () { + $regex = EloquentRegex::builder()->start() + ->lookBehind(function ($pattern) { + $pattern->charSet(function ($pattern) { + $pattern->doubleQuote()->closeAngleBracket()->addRawRegex("\\s"); + }); + })->hash()->toRegex(); + + expect($regex)->toBe('(?<=["\>\s])\#'); +}); + +it('reproduces Text suffix pattern from HSA using wrapper', function () { + $regex = EloquentRegex::builder()->start() + ->openAngleBracket()->slash()->alphanumericRange(0, 10)->closeAngleBracket() + ->toRegex(); + + expect($regex)->toBe('\<\/[a-zA-Z0-9]{0,10}\>'); +}); + +it('constructs regex for simple email validation using wrapper', function () { + $regex = EloquentRegex::builder()->start() + ->textLowercase() + ->atSymbol() + ->textLowercase() + ->dot() + ->textLowercaseRange(2, 4) + ->toRegex(); + + expect($regex)->toBe('[a-z]+@[a-z]+\.[a-z]{2,4}'); +}); + +it('constructs regex for URL validation using wrapper', function () { + $regex = EloquentRegex::builder()->pattern() + ->exact(['http', 'https']) + ->colon() + ->doubleSlash() + ->text() + ->dot() + ->text() + ->toRegex(); + + expect($regex)->toBe('(?:http|https)\:\/\/[a-zA-Z]+\.[a-zA-Z]+'); +}); + +it('constructs regex for specific phone number format using wrapper', function () { + $regex = EloquentRegex::builder()->pattern(function ($p) { + $p->openParenthesis()->digits(3)->closeParenthesis() + ->space() + ->digits(3)->dash()->digits(4); + })->toRegex(); + + expect($regex)->toBe('\(\d{3}\) \d{3}\-\d{4}'); +}); + +it('extracts dates in specific format from text using wrapper', function () { + $matches = EloquentRegex::customPattern("Meeting on 2021-09-15 and 2021-10-20") + ->digits(4) + ->dash() + ->digits(2) + ->dash() + ->digits(2) + ->get(); + + expect($matches)->toEqual(['2021-09-15', '2021-10-20']); +}); + +it('validates usernames in a string using wrapper and LengthOption', function () { + $check = EloquentRegex::customPattern("Users: user_123, JohnDoe_99") + ->alphanumeric() + ->underscore() + ->digitsRange(0, 2) + ->end(["minLength" => 10]) + ->checkString(); + + expect($check)->toBeTrue(); +}); + +it('validates usernames in a string using wrapper and callback options', function () { + $check = EloquentRegex::customPattern("Users: user_123, JohnDoe_99") + ->alphanumeric() + ->underscore() + ->digits() + ->end(function ($p) { + $p->minLength(10)->maxDigits(2); + }) + ->checkString(); + + expect($check)->toBeTrue(); +}); + +it('extracts hashtags from text using wrapper', function () { + $matches = EloquentRegex::start("#hello #world This is a #test") + ->hash() + ->text() + ->get(); + + expect($matches)->toEqual(['#hello', '#world', '#test']); +}); + +it('extracts secret coded messages from text using wrapper', function () { + $text = "Normal text {secret: message one} more text {secret: another hidden text} end"; + $matches = EloquentRegex::start($text) + ->lookBehind(function ($pattern) { + $pattern->openCurlyBrace()->exact('secret: '); + }) + ->lazy()->anyChars() + ->lookAhead(function ($pattern) { + $pattern->closeCurlyBrace(); + }) + ->get(); + + expect($matches)->toEqual(['message one', 'another hidden text']); +}); + +// Ready-to-use pattern tests: + +// TextOrNumbersPattern +it('validates text with numbers correctly', function () { + $builder = EloquentRegex::source("Text123"); + + $check = $builder->textOrNumbers()->check(); + + expect($check)->toBeTrue(); +}); + +// EmailPattern +it('validates an email address correctly', function () { + $builder = EloquentRegex::source("test@example.com"); + + $check = $builder->email()->check(); + + expect($check)->toBeTrue(); +}); + +// DomainNamePattern +it('validates a domain name correctly', function () { + $builder = EloquentRegex::string("example.com"); + + $check = $builder->domainName()->check(); + + expect($check)->toBeTrue(); +}); + +// DatePattern +it('validates a date format correctly', function () { + $builder = EloquentRegex::string("2023-01-01"); + + $check = $builder->date()->check(); + + expect($check)->toBeTrue(); +}); + +// TimePattern +it('validates a time format correctly', function () { + $builder = EloquentRegex::string("23:59"); + + $check = $builder->time('H:i')->check(); + + expect($check)->toBeTrue(); +}); + +// IPAddressPattern +it('validates an IPv4 address correctly', function () { + $builder = EloquentRegex::string("192.168.1.1"); + + $check = $builder->ipAddress()->check(); + + expect($check)->toBeTrue(); +}); + +// IPv6AddressPattern +it('validates an IPv6 address correctly', function () { + $builder = EloquentRegex::string("2001:0db8:85a3:0000:0000:8a2e:0370:7334"); + + $check = $builder->ipv6Address()->check(); + + expect($check)->toBeTrue(); +}); + +// CreditCardNumberPattern +it('validates a credit card number correctly', function () { + $builder = EloquentRegex::source("4111111111111111"); // A common Visa test number + + $check = $builder->creditCardNumber()->check(); + + expect($check)->toBeTrue(); +}); + +// PhonePattern +it('validates a phone number correctly', function () { + $builder = EloquentRegex::string("+1 (123) 456-7890"); + + $check = $builder->phone()->check(); + + expect($check)->toBeTrue(); +}); + +// UsernamePattern +it('validates a username correctly', function () { + $builder = EloquentRegex::string("user_123"); + + $check = $builder->username()->check(); + + expect($check)->toBeTrue(); +}); + +// HtmlTagPattern +it('identifies HTML content correctly', function () { + $builder = EloquentRegex::source("
example
"); + + $check = $builder->htmlTag()->check(); + + expect($check)->toBeTrue(); +}); + +// CurrencyPattern +it('validates currency format correctly', function () { + $builder = EloquentRegex::string("$100.00"); + + $check = $builder->currency()->check(); + + expect($check)->toBeTrue(); +}); + +// FilePathPattern +it('validates a Unix file path correctly', function () { + $string = "/user/directory/file.txt"; + $builder = EloquentRegex::string($string); + + $check = $builder->filePath([ + "isDirectory" => false, + "isFile" => "txt", + ])->check(); + + expect($check)->toBeTrue(); +}); + + +// Quantifier tests: +it('matches specific number of dashes', function () { + $result = EloquentRegex::builder()->pattern()->dash('?')->toRegex(); + expect($result)->toBe('(?:\-)?'); +}); + +it('matches optional dots', function () { + $result = EloquentRegex::builder()->pattern()->dot('?')->toRegex(); + expect($result)->toBe('(?:\.)?'); +}); + +it('matches multiple spaces', function () { + $result = EloquentRegex::builder()->pattern()->space('2,5')->toRegex(); + expect($result)->toBe('(?: ){2,5}'); +}); + +it('matches one or more backslashes', function () { + $result = EloquentRegex::start("\\\\\\")->backslash('+')->check(); + expect($result)->toBe(true); +}); + +it('matches zero or more forward slashes', function () { + $result = EloquentRegex::builder()->start()->forwardSlash('*')->toRegex(); + expect($result)->toBe('(?:\/)*'); +}); + +it('matches exactly 4 underscores', function () { + $result = EloquentRegex::builder()->start()->underscore('4')->toRegex(); + expect($result)->toBe('(?:_){4}'); +}); + +it('matches one or more pipes', function () { + $result = EloquentRegex::builder()->start()->pipe('+')->toRegex(); + expect($result)->toBe('(?:\|)+'); +}); + +it('matches a specific number of character sets', function () { + $regex = EloquentRegex::builder()->start() + ->charSet(function ($pattern) { + $pattern->period()->colon(); + }, '3')->toRegex(); + + expect($regex)->toBe('(?:[\.\:]){3}'); +}); + +it('matches a specific number of negative character sets', function () { + $regex = EloquentRegex::builder()->start() + ->negativeCharSet(function ($pattern) { + // "digits" and similar classes adds quantifier+ automaticaly + // Inside set "+" is parsed as symbol, instead of quantifier + // So, inside charSet and negativeCharSet method, you should + // pass 0 as first argument to do not apply quantifier here + $pattern->digits(); + }, '2,4')->toRegex(); + + expect($regex)->toBe('(?:[^\d]){2,4}'); +}); + +it('matches a specific number of negative character sets using text method', function () { + $regex = EloquentRegex::builder()->start() + ->negativeCharSet(function ($pattern) { + $pattern->text(); + }, '2,4')->toRegex(); + + expect($regex)->toBe('(?:[^a-zA-Z]){2,4}'); +}); + +it('applies quantifier to capturing groups correctly', function () { + $regex = EloquentRegex::builder()->start() + ->group(function ($pattern) { + $pattern->text(); + }, '+')->toRegex(); + + expect($regex)->toBe('(?:([a-zA-Z]+))+'); +}); + +it('applies quantifier to non-capturing groups correctly', function () { + $regex = EloquentRegex::builder()->start() + ->nonCapturingGroup(function ($pattern) { + $pattern->digits(); + }, '*')->toRegex(); + + expect($regex)->toBe('(?:(?:\d+))*'); + + $res = EloquentRegex::start("345-45, 125-787, 344643") + ->nonCapturingGroup(function ($pattern) { + $pattern->digits()->dash()->digits(); + }, '+') // Using "+" to match One Or More of this group + ->get(); + + expect($res)->toBe([ + "345-45", + "125-787" + ]); +}); + +test('group method creates capturing groups correctly', function () { + // Matching a date format across multiple lines without capturing the groups + $result = EloquentRegex::start("2024-01-30, 2023-02-20") + ->group(function($pattern) { + $pattern->digits(4); // Year + })->dash() + ->group(function($pattern) { + $pattern->digits(2); // Month + })->dash() + ->group(function($pattern) { + $pattern->digits(2); // Day + })->end(["excludeChars" => ["4"]]) + ->get(); + + expect($result)->toBe([ + [ + "result" => "2023-02-20", + "groups" => [ + "2023", + "02", + "20" + ], + ] + ]); +}); + +it('uses quantifier with alternation patterns correctly', function () { + $regex = EloquentRegex::builder()->start() + ->group(function ($pattern) { + $pattern->text()->orPattern(function ($pattern) { + $pattern->digits(); + }, "?"); + })->toRegex(); + + expect($regex)->toBe('([a-zA-Z]+|(?:\d+)?)'); +}); + +// Regex flags tests: + +it('uses asCaseInsensitive method to match pattern correctly', function () { + $checkWithFlag = EloquentRegex::source("EXAMPLE@Email.com") + ->start() + ->exact("example") + ->character("@") + ->exact("email.com") + ->end() + ->asCaseInsensitive()->check(); + + expect($checkWithFlag)->toBeTrue(); +}); + + +// Replace feature tests: + + +it('replaces all texts using replace method', function () { + $replaced = EloquentRegex::source("Send to example@email.com or replay to EXAMPLE2@Email.com") + ->email() + ->replace(function($foundString) { + return "Email: " . $foundString; + }); + + expect($replaced)->toBe("Send to Email: example@email.com or replay to Email: EXAMPLE2@Email.com"); +}); + + +it('replaces the same texts using replace method', function () { + $replaced = EloquentRegex::source("Send to example@email.com or replay to example@email.com") + ->email() + ->replace(function($foundString) { + return "Email: " . $foundString; + }); + + expect($replaced)->toBe("Send to Email: example@email.com or replay to Email: example@email.com"); +}); + + +it('accepts PHP functions in replace method', function () { + $replaced = EloquentRegex::source("Send to example-1@email.com or replay to example-2@email.com") + ->email() + ->replace(function($foundString) { + return strToUpper($foundString); + }); + + expect($replaced)->toBe("Send to EXAMPLE-1@EMAIL.COM or replay to EXAMPLE-2@EMAIL.COM"); +}); + +// Search feature tests: + +it('searches multiline string using keyword', function () { + $found = EloquentRegex::source( + " + Whose woods these are I think I know.\n + His house is in the village though;\n + He will not see me stopping here\n + To watch his woods fill up with snow.\n + \n + The woods are lovely, dark and deep,\n + But I have promises to keep,\n + And miles to go before I sleep,\n + And miles to go before I sleep.\n + " + ) + ->search("woods"); + + expect($found)->toBe([ + "Whose woods these are I think I know.", + "To watch his woods fill up with snow.", + "The woods are lovely, dark and deep,", + ]); +}); + +it('searches multiline string using subpattern with ready-to-use patterns', function () { + $found = EloquentRegex::source( + " + Please contact us via email at info@example.com for more details. + For support inquiries, you can also email us at support@example.com. + Our marketing team is reachable at marketing@example.com for collaborations. + For urgent matters, you can reach out through the phone number provided. + Subscribe to our newsletter to stay updated with the latest news. + Feel free to send feedback directly to our office address. + Any emails sent after 5 PM may be responded to the next business day. + Check the FAQ section for answers to common questions. + Social media channels are also available for quick updates. + We value your input and encourage you to share your thoughts. + " + ) + ->search(function ($pattern) { + $pattern->email(); + }); + + expect($found)->toBe([ + 'Please contact us via email at info@example.com for more details.', + 'For support inquiries, you can also email us at support@example.com.', + 'Our marketing team is reachable at marketing@example.com for collaborations.' + ]); +}); + +it('searches multiline string using subpattern with builder pattern methods', function () { + $found = EloquentRegex::source( + " + Discover the latest tips and tricks to boost your productivity. + Join the conversation with #LaravelTips and #WebDevelopment. + Stay updated with our blog for more insightful content. + Follow us on social media and use #CodingMadeEasy to share your journey. + Let’s build something amazing together! + " + ) + ->search(function ($pattern) { + $pattern->start()->hashtag()->alphanumeric(); + }); + + expect($found)->toBe([ + 'Join the conversation with #LaravelTips and #WebDevelopment.', + 'Follow us on social media and use #CodingMadeEasy to share your journey.', + ]); +}); + + + +// SearchReverse feature tests: + +it('Excepts lines from multiline string using keyword', function () { + // Find all logs except INFO type + $found = EloquentRegex::source( + " + [2024-12-23 10:00:00] INFO: User logged in.\n + [2024-12-25 10:05:00] ERROR: Unable to connect to database.\n + [2024-12-25 10:10:00] INFO: User updated profile.\n + [2024-12-15 10:15:00] WARNING: Disk space running low.\n + [2024-12-34 10:20:00] ERROR: Timeout while fetching data.\n + " + ) + ->searchReverse("INFO"); + + expect($found)->toBe([ + '[2024-12-25 10:05:00] ERROR: Unable to connect to database.', + '[2024-12-15 10:15:00] WARNING: Disk space running low.', + '[2024-12-34 10:20:00] ERROR: Timeout while fetching data.', + ]); +}); + + + +it('Excepts lines from multiline string using subpattern', function () { + // Find all logs that don't happened in 25 december + $found = EloquentRegex::source( + " + [2024-12-23 10:00:00] INFO: User logged in.\n + [2024-12-25 10:05:00] ERROR: Unable to connect to database.\n + [2024-12-25 10:10:00] INFO: User updated profile.\n + [2024-12-15 10:15:00] WARNING: Disk space running low.\n + [2024-12-34 10:20:00] ERROR: Timeout while fetching data.\n + " + ) + ->searchReverse(function ($pattern) { + $pattern->start()->numbers()->dash()->numbers()->dash()->exact("25"); + }); + + expect($found)->toBe([ + '[2024-12-23 10:00:00] INFO: User logged in.', + '[2024-12-15 10:15:00] WARNING: Disk space running low.', + '[2024-12-34 10:20:00] ERROR: Timeout while fetching data.', + ]); +}); + + +// Groups tests: +it('returns group data correctly (JIRA issue IDs)', function () { + $found = EloquentRegex::start("RI-2142, PO-2555") + ->group(function ($pattern) { + return $pattern->textUppercase(2); + }, 1) + ->dash() + ->group(function ($pattern) { + return $pattern->digitsRange(2, 4); + }, 1)->get(); + + expect($found)->toBe([ + [ + "result" => "RI-2142", + "groups" => [ + "RI", + "2142" + ] + ], + [ + "result" => "PO-2555", + "groups" => [ + "PO", + "2555" + ] + ] + ]); +}); + + +// Named groups tests: +it('returns named groups data correctly (JIRA issue IDs)', function () { + // Parse JIRA issue IDs + $found = EloquentRegex::start("RI-2142, PO-2555") + ->namedGroup(function ($pattern) { + return $pattern->textUppercase(2); + }, "project", 1) + ->dash() + ->namedGroup(function ($pattern) { + return $pattern->digitsRange(2, 4); + }, "issue", 1)->get(); + + expect($found)->toBe([ + [ + "result" => "RI-2142", + "groups" => [ + "project" => "RI", + "issue" => "2142", + ] + ], + [ + "result" => "PO-2555", + "groups" => [ + "project" => "PO", + "issue" => "2555", + ] + ] + ]); +}); + +// Swap tests: +it('can swap text using callback', function () { + $builder = EloquentRegex::start("RI-2142, RI-1234, PO-2555"); + $result = $builder + ->namedGroup(function ($pattern) { + return $pattern->textUppercase(2); + }, "project", 1) + ->dash() + ->namedGroup(function ($pattern) { + return $pattern->digitsRange(2, 4); + }, "issue", 1) + ->end(); + + $results = $result->swap(function ($data) { + return "The issue #" . $data["issue"] . " of project " . $data["project"] ." is in progress"; + }); + + expect($results)->toBe([ + 'The issue #2142 of project RI is in progress', + 'The issue #1234 of project RI is in progress', + 'The issue #2555 of project PO is in progress' + ]); +}); + +it('can swap text using pattern string', function () { + $builder = EloquentRegex::start("/container-tbilisi-1585, /container-berlin-1234, /container-tbilisi-2555"); + $result = $builder + ->slash() + ->exact("container") + ->dash() + ->namedGroup(function ($pattern) { + return $pattern->text(); + }, "City") + ->dash() + ->namedGroup(function ($pattern) { + return $pattern->digitsRange(2, 5); + }, "id") + ->end(); + + $results = $result->swap("/container/[ID]?city=[CITY]"); + + expect($results)->toBe([ + '/container/1585?city=tbilisi', + '/container/1234?city=berlin', + '/container/2555?city=tbilisi' + ]); +}); diff --git a/namedGroupsTest.php b/namedGroupsTest.php new file mode 100644 index 0000000..f5fb26c --- /dev/null +++ b/namedGroupsTest.php @@ -0,0 +1,27 @@ +pattern(function ($builder) { + return $builder + ->namedGroup(function ($pattern) { + return $pattern->textUppercase(2); + }, "project", 1) + ->dash() + ->namedGroup(function ($pattern) { + return $pattern->digitsRange(2, 4); + }, "issue", 1); +}); + +$results = $result->get(); + +foreach ($results as $item) { + $id = $item["result"]; + $projectPart = $item["groups"]["project"]; + $issueNumber = $item["groups"]["issue"]; + echo "ID: $id; Project: $projectPart; Issue: $issueNumber\n"; +} \ No newline at end of file diff --git a/src/Builder.php b/src/Builder.php index 6c01663..c6ed72b 100644 --- a/src/Builder.php +++ b/src/Builder.php @@ -68,6 +68,7 @@ class Builder implements BuilderContract { ]; protected bool $returnGroups = false; + protected bool $namedGroups = false; /** * Constructs a new Builder instance. @@ -166,8 +167,14 @@ protected function buildResultByGroup(array $matches, array $groups): array { ]; // Use match index to get it's groups $capturedGroupsForThisMatch = []; - foreach ($groups as $groupArray) { - $capturedGroupsForThisMatch[] = $groupArray[$index]; + foreach ($groups as $key => $groupArray) { + if ($this->getNamedGroups()) { + if (is_string($key)) { + $capturedGroupsForThisMatch[$key] = $groupArray[$index]; + } + } else { + $capturedGroupsForThisMatch[] = $groupArray[$index]; + } } // Add captured groups under "groups" key $matchArray["groups"] = $capturedGroupsForThisMatch; @@ -185,6 +192,129 @@ protected function buildResultByGroup(array $matches, array $groups): array { protected function patternIsSet(): bool { return isset($this->pattern) && !empty($this->pattern); } + + /** + * Removes duplicate values from an array of matches. + * + * @return array without duplicate values. + */ + protected function removeDuplicates(): array { + return array_unique($this->getAllMatches()); + } + + /** + * Searches for a keyword in the multiline string. + * + * @param string $keyword The keyword to search for. + * @return array An array of matches or null if no matches are found. + */ + protected function searchByKeyword(string $keyword): ?array { + $builder = new Self($this->str); + $builder->start() + ->anyChars() + ->lookAhead(function ($pattern) use ($keyword) { + $pattern->exact($keyword); + })->anyChars()->end(); // .+(?=$keyword).+ + + return $builder->get(); + } + + /** + * Searches for a pattern in the multiline string. + * + * @param callable $callback The pattern to search for. + * @return array An array of matches or null if no matches are found. + */ + protected function searchBySubpattern(callable $callback): ?array { + // Get pattern from callback + $subPattern = new self($this->str); + $callback($subPattern); + + $builder = new Self($this->str); + $builder->start() + ->anyChars() + ->lookAhead(function ($pattern) use ($subPattern) { + $pattern->addRawRegex($subPattern->toRegex()); + })->anyChars()->end(); + + return $builder->get(); + } + + + /** + * Filters the current multiline string by the given keyword + * And return every line except that includes keyword + * + * @param string $keyword The keyword to filter the string by. + * @return array|null The filtered result as an array, or null if no match is found. + */ + protected function exceptByKeyword(string $keyword): ?array { + $builder = new Self($this->str); + $builder->start() + ->useStringBeginning() + ->negativeLookAhead(function ($pattern) use ($keyword) { + $pattern->anyChars()->exact($keyword); + })->anyChars()->useStringEnd()->end()->asMultiline(); + + return $builder->get(); + } + + /** + * Filters the current multiline string by the given subpattern + * And return every line except that includes subpattern + * + * @param callable $callback A callback function that defines the subpattern. + * @return array|null The resulting pattern as an array, or null if no match is found. + */ + protected function exceptBySubpattern(callable $callback): ?array { + // Get pattern from callback + $subPattern = new self($this->str); + $callback($subPattern); + + $builder = new Self($this->str); + $builder->start() + ->useStringBeginning() + ->negativeLookAhead(function ($pattern) use ($subPattern) { + $pattern->anyChars()->addRawRegex($subPattern->toRegex()); + })->anyChars()->useStringEnd()->end()->asMultiline(); + + return $builder->get(); + } + + protected function swapByCallback(callable $callback, array $results): array { + $swapped = []; + foreach ($results as $match) { + $data = $match['groups']; + $replaced = $callback($data); + $swapped[] = $replaced; + } + return $swapped; + } + + protected function swapByString(string $objectString, array $results): array { + $swapped = []; + foreach ($results as $match) { + $data = $match['groups']; + $swappedString = $objectString; + foreach ($data as $key => $value) { + $pattern = "/\[\s*" . preg_quote($key, '/') . "\s*\]/i"; + $swappedString = preg_replace($pattern, $value, $swappedString); + } + $swapped[] = $swappedString; + } + return $swapped; + } + + protected function returnArrayOrCollection(?array $matches): mixed { + // Check if Laravel Collection class exists and the collect helper function is available + if (class_exists(\Illuminate\Support\Collection::class) && function_exists('collect')) { + // Return matches as a Laravel Collection + return collect($matches); + } + + // Return matches as an array if Collection or collect() is not available + return $matches; + } /* Public methods (API) */ @@ -192,6 +322,8 @@ public function setString(string $str): void { $this->str = $str; } + // Actions start + public function get(): mixed { if (!$this->patternIsSet()) { throw new \LogicException("Pattern must be set before getting matches."); @@ -199,14 +331,7 @@ public function get(): mixed { $matches = $this->getAllMatches(); - // Check if Laravel Collection class exists and the collect helper function is available - if (class_exists(\Illuminate\Support\Collection::class) && function_exists('collect')) { - // Return matches as a Laravel Collection - return collect($matches); - } - - // Return matches as an array if Collection or collect() is not available - return $matches; + return $this->returnArrayOrCollection($matches); } public function check(): bool { @@ -237,6 +362,85 @@ public function toRegex(): string { } return $this->pattern->getPattern(); } + + public function replace(callable $replaceFunction): string { + if (!$this->patternIsSet()) { + throw new \LogicException("Pattern must be set before running replace."); + } + $matches = $this->getAllMatches(); + + if ($matches) { + $matches = $this->removeDuplicates($matches); + $replaced = $this->str; + foreach ($matches as $match) { + $replaced = str_replace($match, $replaceFunction($match), $replaced); + } + return $replaced; + } + } + + public function search(string|callable $keywordOrPattern): mixed { + + if (is_callable($keywordOrPattern)) { + $matches = $this->searchBySubpattern($keywordOrPattern); + } + + if (is_string($keywordOrPattern)) { + $matches = $this->searchByKeyword($keywordOrPattern); + } + + if ($matches) { + $matches = array_map('trim', $matches); + return $this->returnArrayOrCollection($matches); + } + + return null; + } + + public function searchReverse(string|callable $keywordOrPattern): mixed { + + if (is_callable($keywordOrPattern)) { + $matches = $this->exceptBySubpattern($keywordOrPattern); + } + + if (is_string($keywordOrPattern)) { + $matches = $this->exceptByKeyword($keywordOrPattern); + } + + if ($matches) { + $filteredMatches = array_filter(array_map('trim', $matches), function($match) { + return !empty($match); + }); + $filteredMatches = array_values($filteredMatches); + return $this->returnArrayOrCollection($filteredMatches); + } + + return null; + } + + public function swap(string|callable $stringOrCallback): mixed { + // Check if the pattern is set + if (!$this->patternIsSet()) { + throw new \LogicException("Pattern must be set before setting options."); + } + // Check if groups are enabled + if (!$this->getReturnGroups()) { + throw new \LogicException("Swap must be used with groups (group or namedGroup methods)."); + } + + $results = $this->get(); + if (is_callable($stringOrCallback)) { + $matches = $this->swapByCallback($stringOrCallback, $results); + } + if (is_string($stringOrCallback)) { + $matches = $this->swapByString($stringOrCallback, $results); + } + + return $this->returnArrayOrCollection($matches); + } + + // Actions END + // In cases when pattern doesn't allow setting the options (like BuilderPattern) public function setOptions(array|callable $config): self { @@ -330,6 +534,15 @@ public function getReturnGroups(): bool { return $this->returnGroups; } + public function setNamedGroups(bool $enable): self { + $this->namedGroups = $enable; + return $this; + } + + public function getNamedGroups(): bool { + return $this->namedGroups; + } + /** * Dynamically handles calls to pattern methods. * diff --git a/src/Traits/BuilderPatternTraits/AnchorsTrait.php b/src/Traits/BuilderPatternTraits/AnchorsTrait.php index 654f84c..145f9fb 100644 --- a/src/Traits/BuilderPatternTraits/AnchorsTrait.php +++ b/src/Traits/BuilderPatternTraits/AnchorsTrait.php @@ -38,5 +38,25 @@ public function asWord(): self { return $this; } + /** + * Adds a start of line marker at the start of the pattern. + * + * @return self The current instance of the BuilderPattern for method chaining. + */ + public function useStringBeginning(): self { + $this->pattern = '^' . $this->pattern; + return $this; + } + + /** + * Adds a start of line marker at the start of the pattern. + * + * @return self The current instance of the BuilderPattern for method chaining. + */ + public function useStringEnd(): self { + $this->pattern = $this->pattern . '$'; + return $this; + } + // Anchors END } diff --git a/src/Traits/BuilderPatternTraits/GroupsTrait.php b/src/Traits/BuilderPatternTraits/GroupsTrait.php index c6de014..91dc925 100644 --- a/src/Traits/BuilderPatternTraits/GroupsTrait.php +++ b/src/Traits/BuilderPatternTraits/GroupsTrait.php @@ -46,8 +46,27 @@ public function group(callable $callback, ?string $q = null): self { $this->builder->setReturnGroups(true); $subPattern = new self(); $callback($subPattern); + $p = '(' . $subPattern->getPattern() . ')'; + $this->pattern .= $q ? $this->applyQuantifier($p, $q) : $p; + return $this; + } + + /** + * Adds a new grouped subpattern. + * + * @param callable $callback A callback that receives a BuilderPattern instance to define the subpattern. + * @param string $name of a group + * @param ?string $q a Quantifier + * @return self + */ + public function namedGroup(callable $callback, string $name, ?string $q = null): self { + $this->builder->setReturnGroups(true); + $this->builder->setNamedGroups(true); + $subPattern = new self(); + $callback($subPattern); $p = $subPattern->getPattern(); - $this->pattern .= $q ? $this->applyQuantifier($p, $q) : '(' . $p . ')'; + $p = "(?P<$name>" . $p . ')'; + $this->pattern .= $q ? $this->applyQuantifier($p, $q) : $p; return $this; } diff --git a/swapTest.php b/swapTest.php new file mode 100644 index 0000000..5c3c23e --- /dev/null +++ b/swapTest.php @@ -0,0 +1,38 @@ +namedGroup(function ($pattern) { + return $pattern->textUppercase(2); + }, "project", 1) + ->dash() + ->namedGroup(function ($pattern) { + return $pattern->digitsRange(2, 4); + }, "issue", 1)->end(); + +$results = $result->swap(function ($data) { + return "In project '" . $data["project"] . "' issue #" . $data["issue"] . " is in progress"; +}); + +print_r($results); + + +$builder = EloquentRegex::start("/container-tbilisi-1585"); +$result = $builder->slash() + ->exact("container") + ->dash() + ->namedGroup(function ($pattern) { + return $pattern->text(); + }, "CITY", 1) + ->dash() + ->namedGroup(function ($pattern) { + return $pattern->digitsRange(2, 5); + }, "id", 1)->end(); + + $results = $result->swap("/container/[ID]?city=[CITY]"); + + print_r($results); \ No newline at end of file diff --git a/tests/Feature/EloquentRegexTest.php b/tests/Feature/EloquentRegexTest.php index ea973b1..ec432ac 100644 --- a/tests/Feature/EloquentRegexTest.php +++ b/tests/Feature/EloquentRegexTest.php @@ -327,7 +327,7 @@ $pattern->text(); }, '+')->toRegex(); - expect($regex)->toBe('(?:[a-zA-Z]+)+'); + expect($regex)->toBe('(?:([a-zA-Z]+))+'); }); it('applies quantifier to non-capturing groups correctly', function () { @@ -401,3 +401,266 @@ expect($checkWithFlag)->toBeTrue(); }); + +// Replace feature tests: + + +it('replaces all texts using replace method', function () { + $replaced = EloquentRegex::source("Send to example@email.com or replay to EXAMPLE2@Email.com") + ->email() + ->replace(function($foundString) { + return "Email: " . $foundString; + }); + + expect($replaced)->toBe("Send to Email: example@email.com or replay to Email: EXAMPLE2@Email.com"); +}); + + +it('replaces the same texts using replace method', function () { + $replaced = EloquentRegex::source("Send to example@email.com or replay to example@email.com") + ->email() + ->replace(function($foundString) { + return "Email: " . $foundString; + }); + + expect($replaced)->toBe("Send to Email: example@email.com or replay to Email: example@email.com"); +}); + + +it('accepts PHP functions in replace method', function () { + $replaced = EloquentRegex::source("Send to example-1@email.com or replay to example-2@email.com") + ->email() + ->replace(function($foundString) { + return strToUpper($foundString); + }); + + expect($replaced)->toBe("Send to EXAMPLE-1@EMAIL.COM or replay to EXAMPLE-2@EMAIL.COM"); +}); + +// Search feature tests: + +it('searches multiline string using keyword', function () { + $found = EloquentRegex::source( + " + Whose woods these are I think I know.\n + His house is in the village though;\n + He will not see me stopping here\n + To watch his woods fill up with snow.\n + \n + The woods are lovely, dark and deep,\n + But I have promises to keep,\n + And miles to go before I sleep,\n + And miles to go before I sleep.\n + " + ) + ->search("woods"); + + expect($found)->toBe([ + "Whose woods these are I think I know.", + "To watch his woods fill up with snow.", + "The woods are lovely, dark and deep,", + ]); +}); + +it('searches multiline string using subpattern with ready-to-use patterns', function () { + $found = EloquentRegex::source( + " + Please contact us via email at info@example.com for more details. + For support inquiries, you can also email us at support@example.com. + Our marketing team is reachable at marketing@example.com for collaborations. + For urgent matters, you can reach out through the phone number provided. + Subscribe to our newsletter to stay updated with the latest news. + Feel free to send feedback directly to our office address. + Any emails sent after 5 PM may be responded to the next business day. + Check the FAQ section for answers to common questions. + Social media channels are also available for quick updates. + We value your input and encourage you to share your thoughts. + " + ) + ->search(function ($pattern) { + $pattern->email(); + }); + + expect($found)->toBe([ + 'Please contact us via email at info@example.com for more details.', + 'For support inquiries, you can also email us at support@example.com.', + 'Our marketing team is reachable at marketing@example.com for collaborations.' + ]); +}); + +it('searches multiline string using subpattern with builder pattern methods', function () { + $found = EloquentRegex::source( + " + Discover the latest tips and tricks to boost your productivity. + Join the conversation with #LaravelTips and #WebDevelopment. + Stay updated with our blog for more insightful content. + Follow us on social media and use #CodingMadeEasy to share your journey. + Let’s build something amazing together! + " + ) + ->search(function ($pattern) { + $pattern->start()->hashtag()->alphanumeric(); + }); + + expect($found)->toBe([ + 'Join the conversation with #LaravelTips and #WebDevelopment.', + 'Follow us on social media and use #CodingMadeEasy to share your journey.', + ]); +}); + + + +// SearchReverse feature tests: + +it('Excepts lines from multiline string using keyword', function () { + // Find all logs except INFO type + $found = EloquentRegex::source( + " + [2024-12-23 10:00:00] INFO: User logged in.\n + [2024-12-25 10:05:00] ERROR: Unable to connect to database.\n + [2024-12-25 10:10:00] INFO: User updated profile.\n + [2024-12-15 10:15:00] WARNING: Disk space running low.\n + [2024-12-34 10:20:00] ERROR: Timeout while fetching data.\n + " + ) + ->searchReverse("INFO"); + + expect($found)->toBe([ + '[2024-12-25 10:05:00] ERROR: Unable to connect to database.', + '[2024-12-15 10:15:00] WARNING: Disk space running low.', + '[2024-12-34 10:20:00] ERROR: Timeout while fetching data.', + ]); +}); + + + +it('Excepts lines from multiline string using subpattern', function () { + // Find all logs that don't happened in 25 december + $found = EloquentRegex::source( + " + [2024-12-23 10:00:00] INFO: User logged in.\n + [2024-12-25 10:05:00] ERROR: Unable to connect to database.\n + [2024-12-25 10:10:00] INFO: User updated profile.\n + [2024-12-15 10:15:00] WARNING: Disk space running low.\n + [2024-12-34 10:20:00] ERROR: Timeout while fetching data.\n + " + ) + ->searchReverse(function ($pattern) { + $pattern->start()->numbers()->dash()->numbers()->dash()->exact("25"); + }); + + expect($found)->toBe([ + '[2024-12-23 10:00:00] INFO: User logged in.', + '[2024-12-15 10:15:00] WARNING: Disk space running low.', + '[2024-12-34 10:20:00] ERROR: Timeout while fetching data.', + ]); +}); + + +// Groups tests: +it('returns group data correctly (JIRA issue IDs)', function () { + $found = EloquentRegex::start("RI-2142, PO-2555") + ->group(function ($pattern) { + return $pattern->textUppercase(2); + }, 1) + ->dash() + ->group(function ($pattern) { + return $pattern->digitsRange(2, 4); + }, 1)->get(); + + expect($found)->toBe([ + [ + "result" => "RI-2142", + "groups" => [ + "RI", + "2142" + ] + ], + [ + "result" => "PO-2555", + "groups" => [ + "PO", + "2555" + ] + ] + ]); +}); + + +// Named groups tests: +it('returns named groups data correctly (JIRA issue IDs)', function () { + // Parse JIRA issue IDs + $found = EloquentRegex::start("RI-2142, PO-2555") + ->namedGroup(function ($pattern) { + return $pattern->textUppercase(2); + }, "project", 1) + ->dash() + ->namedGroup(function ($pattern) { + return $pattern->digitsRange(2, 4); + }, "issue", 1)->get(); + + expect($found)->toBe([ + [ + "result" => "RI-2142", + "groups" => [ + "project" => "RI", + "issue" => "2142", + ] + ], + [ + "result" => "PO-2555", + "groups" => [ + "project" => "PO", + "issue" => "2555", + ] + ] + ]); +}); + +// Swap tests: +it('can swap text using callback', function () { + $builder = EloquentRegex::start("RI-2142, RI-1234, PO-2555"); + $result = $builder + ->namedGroup(function ($pattern) { + return $pattern->textUppercase(2); + }, "project", 1) + ->dash() + ->namedGroup(function ($pattern) { + return $pattern->digitsRange(2, 4); + }, "issue", 1) + ->end(); + + $results = $result->swap(function ($data) { + return "The issue #" . $data["issue"] . " of project " . $data["project"] ." is in progress"; + }); + + expect($results)->toBe([ + 'The issue #2142 of project RI is in progress', + 'The issue #1234 of project RI is in progress', + 'The issue #2555 of project PO is in progress' + ]); +}); + +it('can swap text using pattern string', function () { + $builder = EloquentRegex::start("/container-tbilisi-1585, /container-berlin-1234, /container-tbilisi-2555"); + $result = $builder + ->slash() + ->exact("container") + ->dash() + ->namedGroup(function ($pattern) { + return $pattern->text(); + }, "City") + ->dash() + ->namedGroup(function ($pattern) { + return $pattern->digitsRange(2, 5); + }, "id") + ->end(); + + $results = $result->swap("/container/[ID]?city=[CITY]"); + + expect($results)->toBe([ + '/container/1585?city=tbilisi', + '/container/1234?city=berlin', + '/container/2555?city=tbilisi' + ]); +});