From 279ae5013eceb3e483cc4e70b19e302c92cc2977 Mon Sep 17 00:00:00 2001 From: Matronator <5470780+matronator@users.noreply.github.com> Date: Thu, 6 Jun 2024 01:45:46 +0200 Subject: [PATCH] fix tests, fix else support, deprecate methods, update readme (finally), license --- .gitignore | 1 + LICENSE | 2 +- README.md | 79 +++---- composer.lock | 38 ++-- helpers.php | 5 + src/Parsem/Parser.php | 425 +++++++++++++++++++++--------------- tests/Parsem/ParserTest.php | 140 +++++++++++- 7 files changed, 451 insertions(+), 239 deletions(-) diff --git a/.gitignore b/.gitignore index 45060af..4b0c3fa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ vendor parsem.sketch .DS_Store dump.log +.idea/ diff --git a/LICENSE b/LICENSE index 9f37955..05e4fd0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Matronator +Copyright (c) 2022-2024 Matronator Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index f181fd1..7c62c44 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ ![Pars'Em logo](.github/parsem-logo.png) -Simple lightweight templating engine made for (primarily) JSON, YAML and NEON templates. +Simple lightweight templating engine made in PHP. -Enhance your JSON/YAML/NEON files with variables and PHP functions as filters. Create re-usable templates by adding variable `<% placeholder %>`'s anywhere in your file and have the content change dynamically based on the arguments you provide. +Enhance your files with variables, conditional blocks and PHP functions as filters. Create re-usable templates by adding variable `<% placeholder %>`'s anywhere in your file and have the content change dynamically based on the arguments you provide. @@ -18,16 +18,14 @@ Enhance your JSON/YAML/NEON files with variables and PHP functions as filters. C - [Template syntax](#template-syntax) - [Conditions](#conditions) - [Variables](#variables) - - [Default values](#default-values) + - [Default values](#default-values) - [Filters](#filters) - [Built-in filters](#built-in-filters) - [Use in code](#use-in-code) - [Parse string or file](#parse-string-or-file) - [Methods](#methods) - [`Parser::parseString`](#parserparsestring) - - [`Parser::parseFile`](#parserparsefile) - - [`Parser::parseFileToString`](#parserparsefiletostring) - - [Using custom parser](#using-custom-parser) + - [`Parser::parse`](#parserparse) @@ -36,14 +34,13 @@ Enhance your JSON/YAML/NEON files with variables and PHP functions as filters. C - Parse string templates to string - Replace variable placeholders with provided arguments - Apply filter functions to variables + - Use [built-in filters](#built-in-filters) or provide custom functions - Use `<% if %>` blocks to conditionally parse the template + - Use `<% else %>` blocks to provide an alternative content if the condition is not met - Parse template files to string - - Parse the entire file as a string regardless of extension + - Parse the entire file as a string - Provide a custom regex pattern to parse functions to use a custom syntax -- Convert JSON, YAML and NEON files to a PHP object -- Convert any file type to a PHP object by providing a custom parsing function -- Get all variable placeholders from a string -- Validate a template file against the [mtrgen-template-schema](https://www.mtrgen.com/storage/schemas/template/latest/mtrgen-template-schema.json) +- Get all variables from a template ## Requirements @@ -66,7 +63,7 @@ use Matronator\Parsem\Parser; ### Templates Syntax Highlighting for VS Code -To get syntax highlighting for template files (highlight `<% variable|placeholders %>` even inside strings), you can download the [MTRGen Templates Syntax Highlighting](https://marketplace.visualstudio.com/items?itemName=matronator.mtrgen-yaml-templates) extension for VS Code. +To get syntax highlighting for template files (highlight `<% variable|placeholders %>` and `<% if %><% else %><% endif %>` even inside strings), you can download the [MTRGen Templates Syntax Highlighting](https://marketplace.visualstudio.com/items?itemName=matronator.mtrgen-yaml-templates) extension for VS Code. ## Usage @@ -76,6 +73,8 @@ To get syntax highlighting for template files (highlight `<% variable|placeholde You can use conditions in your templates by using the `<% if %>` and `<% endif %>` tags. The condition must be a valid PHP expression that will be evaluated and if it returns `true`, the content between the tags will be included in the final output. +You can also use the `<% else %>` tag to provide an alternative content if the condition is not met. + To use a variable provided in the arguments array in a condition, you must use the `$` sign before the variable name, like this: `<% if $variable == 'value' %>`. The `$` sign is used to differentiate between the template variable and a keyword such as `true` or `null`. ##### Example: @@ -85,6 +84,8 @@ some: key <% if $variable == 'value' %> with value + <% else %> + without value <% endif %> ``` @@ -96,6 +97,14 @@ some: with value ``` +And if you provide an argument `['variable' => 'other value']`, the final output will be this: + +```yaml +some: + key + without value +``` + #### Variables Variables are wrapped in `<%` and `%>` with optional space on either side (both `<%nospace%>` and `<% space %>` are valid) and the name must be an alphanumeric string with optional underscore/s (this regex `[a-zA-Z0-9_]+?`). @@ -106,6 +115,8 @@ Variables can optionally have a default value that will be used if no argument i If you're going to use filters, the default value comes before the filter, ie.: `<% variable='Default'|filter %>` +If default value is empty (ie. `<% var= %>`), it will be treated as null. + #### Filters You can optionally provide filter to a variable by placing the pipe symbol `|` right after the variable name and the filter right after that (no space around the `|` pipe symbol), like this: `<% variable|filter %>`. @@ -164,23 +175,22 @@ There are a few built-in filters that you can use: #### Parse string or file -There are three main functions that will be of most interest to you: `parseString`, `parseFile` and `parseFileToString`. Both are static functions and are used like this: +There are two main functions that will be of most interest to you: `parseString` and `parse`. Both are static functions and are used like this: ```php use Matronator\Parsem\Parser; +// parseString() echo Parser::parseString('some <%text%>.', ['text' => 'value']); // Output: some value. +// parse() $arguments = [ 'variableName' => 'value', 'key' => 'other value', ]; -$object = Parser::parseFile('filename.yaml', $arguments); -// Output: The YAML file converted to an object with all -// variable placeholders replaced by the provided arguments. - -echo Parser::parseFileToString('filename.yaml', $arguments); +$parsedFile = Parser::parse('filename.yaml', $arguments); +echo $parsedFile; // Output: Will print the parsed contents of the file as string. ``` @@ -194,38 +204,19 @@ echo Parser::parseFileToString('filename.yaml', $arguments); * @return mixed The parsed string or the original `$string` value if it's not string * @param mixed $string String to parse. If not provided with a string, the function will return this value * @param array $arguments Array of arguments to find and replace while parsing `['key' => 'value']` + * @param bool $strict [optional] If set to `true`, the function will throw an exception if a variable is not found in the `$arguments` array. If set to `false` null will be used. * @param string|null $pattern [optional] You can provide custom regex with two matching groups (for the variable name and for the filter) to use custom template syntax instead of the default one `<% name|filter %>` + * @throws RuntimeException If a variable is not found in the `$arguments` array and `$strict` is set to `true` */ -Parser::parseString(mixed $string, array $arguments = [], ?string $pattern = null): mixed -``` - -##### `Parser::parseFile` - -```php -/** - * @see Parser::parseString() for parameter descriptions - */ -Parser::parseFile(string $filename, array $arguments = [], ?string $pattern = null): object +Parser::parseString(mixed $string, array $arguments = [], bool $strict = true, ?string $pattern = null): mixed ``` -##### `Parser::parseFileToString` +##### `Parser::parse` ```php /** - * @see Parser::parseString() for parameter descriptions + * @param string $filename Path to the file to parse + * @see Parser::parseString() for rest of the parameter descriptions */ -Parser::parseFileToString(string $filename, array $arguments = [], ?string $pattern = null): string -``` - -#### Using custom parser - -You can parse any file type to object, not only JSON/YAML/NEON, by providing a custom parser function as a callback to `Parser::customParse()` function. - -```php -use Matronator\Parsem\Parser; - -// Parse XML file -$object = Parser::customParse('filename.xml', function($contents) { - return simplexml_load_string($contents); -}); +Parser::parse(string $filename, array $arguments = [], bool $strict = true, ?string $pattern = null): string ``` diff --git a/composer.lock b/composer.lock index ebd1b8e..51aac20 100644 --- a/composer.lock +++ b/composer.lock @@ -12,17 +12,17 @@ "source": { "type": "git", "url": "https://github.com/nette/neon.git", - "reference": "d016eb143f513873043d9d4d645d2d02d3afab1f" + "reference": "b9a2bdda27535c1613b4f6429778315f5647f9c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/neon/zipball/d016eb143f513873043d9d4d645d2d02d3afab1f", - "reference": "d016eb143f513873043d9d4d645d2d02d3afab1f", + "url": "https://api.github.com/repos/nette/neon/zipball/b9a2bdda27535c1613b4f6429778315f5647f9c6", + "reference": "b9a2bdda27535c1613b4f6429778315f5647f9c6", "shasum": "" }, "require": { "ext-json": "*", - "php": ">=8.0 <8.3" + "php": "8.0 - 8.3" }, "require-dev": { "nette/tester": "^2.4", @@ -36,7 +36,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "3.5-dev" } }, "autoload": { @@ -73,7 +73,7 @@ "issues": "https://github.com/nette/neon/issues", "source": "https://github.com/nette/neon/tree/master" }, - "time": "2023-02-06T19:24:05+00:00" + "time": "2024-05-16T14:00:49+00:00" }, { "name": "opis/json-schema", @@ -350,16 +350,16 @@ }, { "name": "symfony/yaml", - "version": "v7.1.0", + "version": "7.2.x-dev", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "c5f718c94e3c37dd77b77484e6cf0b524b2d405e" + "reference": "412589da9efe781abff81770478ec88466ab3082" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/c5f718c94e3c37dd77b77484e6cf0b524b2d405e", - "reference": "c5f718c94e3c37dd77b77484e6cf0b524b2d405e", + "url": "https://api.github.com/repos/symfony/yaml/zipball/412589da9efe781abff81770478ec88466ab3082", + "reference": "412589da9efe781abff81770478ec88466ab3082", "shasum": "" }, "require": { @@ -401,7 +401,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.1.0" + "source": "https://github.com/symfony/yaml/tree/7.2" }, "funding": [ { @@ -417,7 +417,7 @@ "type": "tidelift" } ], - "time": "2024-04-28T18:29:00+00:00" + "time": "2024-06-02T19:15:21+00:00" } ], "packages-dev": [ @@ -427,16 +427,16 @@ "source": { "type": "git", "url": "https://github.com/nette/tester.git", - "reference": "9497147b6260b21ab808d451154075698e0b27d6" + "reference": "363c1b1e552c4b832ace4dc2325933b26a77bd8f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/tester/zipball/9497147b6260b21ab808d451154075698e0b27d6", - "reference": "9497147b6260b21ab808d451154075698e0b27d6", + "url": "https://api.github.com/repos/nette/tester/zipball/363c1b1e552c4b832ace4dc2325933b26a77bd8f", + "reference": "363c1b1e552c4b832ace4dc2325933b26a77bd8f", "shasum": "" }, "require": { - "php": ">=8.0 <8.3" + "php": ">=8.0 <8.4" }, "require-dev": { "ext-simplexml": "*", @@ -495,7 +495,7 @@ "issues": "https://github.com/nette/tester/issues", "source": "https://github.com/nette/tester/tree/master" }, - "time": "2023-05-04T08:16:59+00:00" + "time": "2024-05-16T14:14:27+00:00" } ], "aliases": [], @@ -509,7 +509,7 @@ }, "platform-dev": [], "platform-overrides": { - "php": "8.1" + "php": "8.2" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/helpers.php b/helpers.php index 0a87ac3..3752613 100644 --- a/helpers.php +++ b/helpers.php @@ -1,8 +1,13 @@ + */ final class Parser { - // Old pattern for backup - // public const PATTERN = '/<%\s?([a-zA-Z0-9_]+)\|?([a-zA-Z0-9_]+?)?\s?%>/m'; - /** * Matches: * 0: <% var='default'|filter:10,'arg','another' %> --> (full match) * 1: var --> (only variable name) * 2: ='default' --> (default value) - * 3: filter:10,'arg','another' --> (filter with args) + * 3: |filter:10,'arg','another' --> (filter with args) * 4: filter --> (only filter name) */ - public const VARIABLE_PATTERN = '/<%\s?((?!endif|else)[a-zA-Z0-9_]+)(=.+?)?\|?(([a-zA-Z0-9_]+?)(?:\:(?:(?:\\?\'|\\?")?.?(?:\\?\'|\\?")?,?)+?)*?)?\s?%>/m'; + public const string VARIABLE_PATTERN = '/<%\s?((?!endif|else)[a-zA-Z0-9_]+)(=.*?)?(\|([a-zA-Z0-9_]+?)(?:\:(?:(?:\\?\'|\\?")?.?(?:\\?\'|\\?")?,?)+?)*?)?\s?%>/m'; /** * Matches: @@ -37,9 +39,29 @@ final class Parser * 5: > --> (operator) * 6: 10 --> (right side) */ - public const CONDITION_PATTERN = '/(?<%\s?if\s(?(?!?)(?\S+?)\s?(?(?(?:<=|<|===|==|>=|>|!==|!=))\s?(?.+?))?)\s?%>\n?)/m'; + public const string CONDITION_PATTERN = '/(?<%\s?if\s(?(?!?)(?\S+?)\s?(?(?(?:<=|<|===|==|>=|>|!==|!=))\s?(?.+?))?)\s?%>\n?)/m'; - public const LITERALLY_NULL = '__:-LITERALLY_NULL-:__'; + /** @internal */ + private const string LITERALLY_NULL = '⚠︎__:-␀LITERALLY_NULL␀-:__⚠︎'; + + /** + * Parses a file to a PHP object, replacing all template variables with the provided `$arguments` values. + * @since 3.2.0 + * @return string The parsed string + * @param string $filename File to parse + * @param array $arguments Array of arguments to find and replace while parsing `['key' => 'value']` + * @param bool $strict [optional] If set to `true`, the function will throw an exception if a variable is not found in the `$arguments` array. If set to `false` null will be used. + * @param string|null $pattern [optional] You can provide custom regex with two matching groups (for the variable name and for the filter) to use custom template syntax instead of the default one `<% name|filter %>` + * @throws RuntimeException If the file does not exist + */ + public static function parse(string $filename, array $arguments = [], bool $strict = true, ?string $pattern = null): string + { + if (!file_exists($filename)) + throw new RuntimeException("File '$filename' does not exist."); + + $contents = file_get_contents($filename); + return static::parseString($contents, $arguments, $strict, $pattern); + } /** * Parses a string, replacing all template variables with the corresponding values passed in `$arguments`. @@ -48,12 +70,13 @@ final class Parser * @param array $arguments Array of arguments to find and replace while parsing `['key' => 'value']` * @param bool $strict [optional] If set to `true`, the function will throw an exception if a variable is not found in the `$arguments` array. If set to `false` null will be used. * @param string|null $pattern [optional] You can provide custom regex with two matching groups (for the variable name and for the filter) to use custom template syntax instead of the default one `<% name|filter %>` + * @throws RuntimeException If a variable is not found in the `$arguments` array and `$strict` is set to `true` */ public static function parseString(mixed $string, array $arguments = [], bool $strict = true, ?string $pattern = null): mixed { if (!is_string($string)) return $string; - $string = self::parseConditions($string, $arguments); + $string = static::parseConditions($string, $arguments); preg_match_all($pattern ?? static::VARIABLE_PATTERN, $string, $matches); $args = []; @@ -79,169 +102,62 @@ public static function parseString(mixed $string, array $arguments = [], bool $s return str_replace($matches[0], $args, $string); } - public static function parseConditions(string $string, array $arguments = [], int $offset = 0): string + /** + * Parses a string, replacing all conditional blocks (`<% if ... %>...<% else %>...<% endif %>`) depending on the result of the condition. + * @return string The parsed string + * @param string $string String to parse + * @param array $arguments Array of arguments from the template + * @param int $offset [optional] Offset to start searching for the condition from + * @param string|null $pattern [optional] You can provide custom regex for the `<% if %>` tag syntax. + */ + public static function parseConditions(string $string, array $arguments = [], int $offset = 0, ?string $pattern = null): string { - preg_match(static::CONDITION_PATTERN, $string, $matches, PREG_OFFSET_CAPTURE, $offset); + preg_match($pattern ?? static::CONDITION_PATTERN, $string, $matches, PREG_OFFSET_CAPTURE, $offset); if (!$matches) { return $string; } $result = static::getConditionResult($matches, $arguments); - $conditionStart = $matches[0][1]; + $conditionStart = (int)$matches[0][1]; $conditionLength = strlen($matches[0][0]); - - $nestedIfs = preg_match_all(static::CONDITION_PATTERN, $string, $nestedMatches, PREG_OFFSET_CAPTURE, $offset + $conditionLength); - if ($nestedIfs > 0) { - $string = self::parseConditions($string, $arguments, (int)$conditionStart + (int)$conditionLength, $offset); - } + $insideBlockStart = $conditionStart + $conditionLength; $hasElse = false; - $elseCount = preg_match('/<%\s?else\s?%>\n?/', $string, $elseMatches, PREG_OFFSET_CAPTURE, $offset); if ($elseCount !== false && $elseCount === 1 && $elseMatches) { $hasElse = true; - preg_match('/<%\s?endif\s?%>\n?/', $string, $endMatches, PREG_OFFSET_CAPTURE, $offset); - if (!$endMatches) { - throw new RuntimeException("Missing <% endif %> tag."); - } - - $elseStart = $elseMatches[0][1]; + $elseStart = (int) $elseMatches[0][1]; $elseTagLength = strlen($elseMatches[0][0]); - $conditionEnd = $endMatches[0][1]; - - $elseBlock = substr($string, $elseStart + $elseTagLength, $conditionEnd - $elseStart - $elseTagLength); + $elseBlock = static::parseElseTag($string, $elseStart, $elseTagLength, $arguments, $offset); } else if ($elseCount > 1) { throw new RuntimeException("Too many <% else %> tags."); + } else { + $string = static::parseNestedIfs($string, $arguments, $offset + $conditionLength, $insideBlockStart); } preg_match('/<%\s?endif\s?%>\n?/', $string, $endMatches, PREG_OFFSET_CAPTURE, $offset); if (!$endMatches) { throw new RuntimeException("Missing <% endif %> tag."); } - - if ($hasElse) { - $conditionEnd = $endMatches[0][1]; - $insideBlock = substr($string, $conditionStart + $conditionLength, $elseStart - $conditionStart - $conditionLength); - $string = substr_replace($string, $result ? $insideBlock : $elseBlock, (int)$conditionStart, $conditionEnd - $conditionStart + strlen($endMatches[0][0])); - } else { - $conditionEnd = $endMatches[0][1]; - $insideBlock = substr($string, $conditionStart + $conditionLength, $conditionEnd - $conditionStart - $conditionLength); - $string = substr_replace($string, $result ? $insideBlock : '', (int)$conditionStart, $conditionEnd - $conditionStart + strlen($endMatches[0][0])); - } - - preg_match(static::CONDITION_PATTERN, $string, $matches, PREG_OFFSET_CAPTURE, $offset); - if (!$matches) { - return $string; - } - - return self::parseConditions($string, $arguments, (int)$conditionStart); - } + $conditionEnd = $endMatches[0][1]; + $replaceLength = $conditionEnd - $conditionStart + strlen($endMatches[0][0]); - public static function getConditionResult(array $matches, array $arguments = []): bool - { - $left = $matches['left'][0]; - $negation = $matches['negation'][0] ?? null; - $operator = $matches['operator'][0] ?? null; - $right = $matches['value'][0] ?? null; - - if (str_starts_with($left, '$')) { - $left = substr($left, 1); - if (!isset($arguments[$left])) { - throw new RuntimeException("Variable '$left' not found in arguments."); - } - - if ($negation === '!') { - $left = !$arguments[$left]; - } else { - $left = $arguments[$left]; - } - } else { - if ((str_starts_with($left, '"') && str_ends_with($left, '"')) || (str_starts_with($left, "'") && str_ends_with($left, "'"))) { - $left = substr($left, 1, -1); - } else if ($left === 'true') { - $left = true; - } else if ($left === 'false') { - $left = false; - } else if ($left === 'null') { - $left = null; - } else if (str_contains($left, '.')) { - $left = floatval($left); - } else if (is_numeric($left) && !str_contains($left, '.')) { - $left = intval($left); - } else { - $left = (string)$left; - } - } - - if (isset($right)) { - $rightNegated = false; - - if (str_starts_with($right, '!')) { - $rightNegated = true; - $right = substr($right, 1); - } - - if (str_starts_with($right, '$')) { - $right = substr($right, 1); - if (!isset($arguments[$right])) { - throw new RuntimeException("Variable '$right' not found in arguments."); - } - - $right = $arguments[$right]; - } else { - if ((str_starts_with($right, '"') && str_ends_with($right, '"')) || (str_starts_with($right, "'") && str_ends_with($right, "'"))) { - $right = substr($right, 1, -1); - } else if ($right === 'true') { - $right = true; - } else if ($right === 'false') { - $right = false; - } else if ($right === 'null') { - $right = null; - } else if (str_contains($right, '.')) { - $right = floatval($right); - } else if (is_numeric($right) && !str_contains($right, '.')) { - $right = intval($right); - } else { - $right = (string)$right; - } - } - - if ($rightNegated) { - $right = !$right; - } - - if ($operator === '==') { - $result = $left == $right; - } elseif ($operator === '===') { - $result = $left === $right; - } elseif ($operator === '!=') { - $result = $left != $right; - } elseif ($operator === '!==') { - $result = $left !== $right; - } elseif ($operator === '<') { - $result = $left < $right; - } elseif ($operator === '<=') { - $result = $left <= $right; - } elseif ($operator === '>') { - $result = $left > $right; - } elseif ($operator === '>=') { - $result = $left >= $right; - } else if (!isset($operator)) { - $result = $left; - } else { - throw new RuntimeException("Unsupported operator '$operator'."); - } + if ($hasElse) { + $insideBlock = substr($string, $insideBlockStart, $elseStart - $insideBlockStart); + $string = substr_replace($string, $result ? $insideBlock : $elseBlock, $conditionStart, $replaceLength); } else { - $result = $left; + $insideBlock = substr($string, $insideBlockStart, $conditionEnd - $insideBlockStart); + $string = substr_replace($string, $result ? $insideBlock : '', $conditionStart, $replaceLength); } - return $result; + return static::parseConditions($string, $arguments, $conditionStart); } /** * Converts a YAML, JSON or NEON file to a corresponding PHP object, replacing all template variables with the provided `$arguments` values. + * @deprecated 3.2.0 __Will be removed in the next version.__ This was used for parsing JSON/YAML/NEON templates in v1 and is no longer needed in v2 and later. * @return object * @param string $filename * @param array $arguments Array of arguments to find and replace while parsing `['key' => 'value']` @@ -249,20 +165,29 @@ public static function getConditionResult(array $matches, array $arguments = []) */ public static function parseFile(string $filename, array $arguments = [], ?string $pattern = null): object { - return self::decodeByExtension($filename, self::parseFileToString($filename, $arguments, $pattern)); + return static::decodeByExtension($filename, static::parseFileToString($filename, $arguments, $pattern)); } + /** + * Parses a file to a string, replacing all template variables with the provided `$arguments` values. + * @deprecated 3.2.0 Use {@see Parser::parse()} instead + * @return string The parsed string + * @param string $filename + * @param array $arguments Array of arguments to find and replace while parsing `['key' => 'value']` + * @param string|null $pattern [optional] You can provide custom regex with two matching groups (for the variable name and for the filter) to use custom template syntax instead of the default one `<% name|filter %>` + */ public static function parseFileToString(string $filename, array $arguments = [], ?string $pattern = null): string { if (!file_exists($filename)) throw new RuntimeException("File '$filename' does not exist."); $contents = file_get_contents($filename); - return self::parseString($contents, $arguments, false, $pattern); + return static::parseString($contents, $arguments, false, $pattern); } /** * Converts a YAML, JSON or NEON file to a corresponding PHP object. + * @deprecated 3.2.0 __Will be removed in the next version.__ This was used for parsing JSON/YAML/NEON templates in v1 and is no longer needed in v2 and later. * @return object * @param string $filename * @param string|null $contents [optional] You can also provide the file's content as a string, but you still have to provide a `$filename` to know which format to parse (YAML, JSON or NEON). @@ -304,6 +229,7 @@ public static function decodeByExtension(string $filename, ?string $contents = n /** * Parse a file of any type to object using a custom provided parser function. + * @deprecated 3.2.O __Will be removed in the next version.__ This was used for parsing JSON/YAML/NEON templates in v1 and is no longer needed in v2 and later. * @return object * @param string $filename * @param callable $function The parsing function with the following signature `function(string $contents): object` where `$contents` will be the string content of `$filename`. @@ -318,7 +244,7 @@ public static function customParse(string $filename, callable $function, array $ if (!is_callable($function)) throw new InvalidArgumentException("Argument \$function is not callable."); - return $function(self::parseFileToString($filename, $arguments, $pattern)); + return $function(static::parseFileToString($filename, $arguments, $pattern)); } /** @@ -326,11 +252,12 @@ public static function customParse(string $filename, callable $function, array $ * @return boolean True if the file is a valid template, false otherwise. * @param string $filename * @param string|null $contents [optional] You can also provide the file's content as a string, but you still have to provide a `$filename` to know which format to parse (YAML, JSON or NEON). + * @deprecated 3.2.0 __Will be removed in the next version.__ This was used for checking JSON/YAML/NEON templates in v1 and is no longer needed in v2 and later. */ public static function isValid(string $filename, ?string $contents = null): bool { try { - $parsed = self::decodeByExtension($filename, $contents); + $parsed = static::decodeByExtension($filename, $contents); } catch (Exception $e) { return false; } @@ -362,7 +289,7 @@ public static function isValidBundle(string $filename, ?string $contents = null) } try { - $parsed = self::decodeByExtension($filename, $contents); + $parsed = static::decodeByExtension($filename, $contents); } catch (Exception $e) { return false; } @@ -374,20 +301,20 @@ public static function isValidBundle(string $filename, ?string $contents = null) /** * Find all (unique) variables in the `$string` template and return them as array with optional default values. - * @return object + * @return object{arguments: array, defaults: array} * @param string $string String to parse. * @param string|null $pattern $pattern [optional] You can provide custom regex with two matching groups (for the variable name and for the filter) to use custom template syntax instead of the default one `<% name|filter %>` */ public static function getArguments(string $string, ?string $pattern = null): object { - preg_match_all($pattern ?? self::VARIABLE_PATTERN, $string, $matches); + preg_match_all($pattern ?? static::VARIABLE_PATTERN, $string, $matches); $arguments = static::removeDuplicates($matches[1]); $defaults = []; foreach ($matches[1] as $key => $match) { if ($matches[2][$key] !== '') { - $default = self::getDefaultValue($matches[2][$key]); + $default = static::getDefaultValue($matches[2][$key]); if ($default !== static::LITERALLY_NULL) { $defaults[$key] = $default; } @@ -413,6 +340,7 @@ public static function applyFilters(array $matches, array $arguments): array foreach ($arguments as $key => $arg) { if ($matches[4][$key]) { $filter = $matches[4][$key]; + $matches[3][$key] = ltrim($matches[3][$key], '|'); if ($matches[3][$key] && $matches[3][$key] !== $filter) { $filterWithArgs = explode(':', $matches[3][$key]); $args = explode(',', $filterWithArgs[1]); @@ -449,9 +377,15 @@ public static function applyFilters(array $matches, array $arguments): array return $modified; } + /** + * Check if the `$string` template needs any arguments to be parsed. + * @return boolean True if the template needs arguments, false otherwise. + * @param string $string String to parse. + * @param string|null $pattern $pattern [optional] You can provide custom regex with two matching groups (for the variable name and for the filter) to use custom template syntax instead of the default one `<% name|filter %>` + */ public static function needsArguments(string $string, ?string $pattern = null): bool { - preg_match_all($pattern ?? self::VARIABLE_PATTERN, $string, $matches); + preg_match_all($pattern ?? static::VARIABLE_PATTERN, $string, $matches); foreach ($matches[2] as $match) { if ($match === '') { return true; @@ -461,42 +395,187 @@ public static function needsArguments(string $string, ?string $pattern = null): return false; } - private static function removeDuplicates(array $array): array + /** + * Get the result of the condition. + * @param array $matches The matches array from a `preg_match` function + * @param array $arguments Array with the arguments (variables) from the template + * @return bool The result of the condition + */ + protected static function getConditionResult(array $matches, array $arguments = []): bool { - $uniqueValues = []; - foreach ($array as $value) { - if (in_array($value, $uniqueValues)) { - $value = null; - } else { - $uniqueValues[] = $value; + $left = $matches['left'][0]; + $negation = $matches['negation'][0] ?? null; + $operator = $matches['operator'][0] ?? null; + $right = $matches['value'][0] ?? null; + + $left = static::transformConditionValue($left, $arguments); + if ($negation === '!') { + $left = !$left; + } + + if (isset($right)) { + $right = static::transformConditionValue($right, $arguments); + $result = static::getResultByOperator($left, $operator, $right); + } else { + $result = $left; + } + + return $result; + } + + /** + * Transform the value of a condition to the correct type. + * @param string $value The value to transform + * @param array $arguments Array with the arguments (variables) from the template + * @return mixed The transformed value + */ + protected static function transformConditionValue(string $value, array $arguments = []): mixed + { + $valueNegated = false; + if (str_starts_with($value, '!')) { + $valueNegated = true; + $value = substr($value, 1); + } + + if (str_starts_with($value, '$')) { + $value = substr($value, 1); + if (!array_key_exists($value, $arguments)) { + throw new RuntimeException("Variable '$value' not found in arguments."); } + + $value = $arguments[$value]; + } else { + $value = static::convertArgumentType($value); + } + + return $valueNegated ? !$value : $value; + } + + /** + * Convert the argument value to the correct type. + * @param string $value The value to convert + * @return mixed The converted value + */ + protected static function convertArgumentType(string $value): mixed + { + if ((str_starts_with($value, '"') && str_ends_with($value, '"')) || (str_starts_with($value, "'") && str_ends_with($value, "'"))) { + return substr($value, 1, -1); + } else if ($value === 'true') { + return true; + } else if ($value === 'false') { + return false; + } else if ($value === 'null') { + return null; + } else if (str_contains($value, '.')) { + return floatval($value); + } else if (is_numeric($value) && !str_contains($value, '.')) { + return intval($value); + } + + return (string)$value; + } + + /** + * Get the result of the comparison between the left and right values using the operator. + * @param mixed $left The left side of the comparison + * @param string|null $operator The operator to use for the comparison + * @param mixed $right The right side of the comparison + * @return bool The result of the comparison + */ + protected static function getResultByOperator(mixed $left, ?string $operator, mixed $right): bool + { + switch ($operator) { + case '==': + return $left == $right; + case '===': + return $left === $right; + case '!=': + return $left != $right; + case '!==': + return $left !== $right; + case '<': + return $left < $right; + case '<=': + return $left <= $right; + case '>': + return $left > $right; + case '>=': + return $left >= $right; + case null: + return $left; + default: + throw new RuntimeException("Unsupported operator '$operator'."); } - return $uniqueValues; } /** - * @param string $defaultMatch - * @param array $defaults - * @return mixed + * Parse the else block of the condition. + * @param string &$string The string to parse (by reference - will be modified in place) + * @param int $elseStart The position of the else tag in the string + * @param int $elseTagLength The length of the else tag + * @param array $arguments Array with the arguments (variables) from the template + * @param int $offset Offset to start replacing the condition from + * @return string The parsed else block */ - private static function getDefaultValue(string $defaultMatch): mixed + protected static function parseElseTag(string &$string, int $elseStart, int $elseTagLength, array $arguments = [], $offset = 0): string { - $default = trim($defaultMatch, '=') ?? null; + $string = static::parseNestedIfs($string, $arguments, $elseStart + $elseTagLength, (int)$elseStart + $elseTagLength); + + preg_match('/<%\s?endif\s?%>\n?/', $string, $endMatches, PREG_OFFSET_CAPTURE, $offset); + if (!$endMatches) { + throw new RuntimeException("Missing <% endif %> tag."); + } + + $conditionEnd = $endMatches[0][1]; + + $elseBlock = substr($string, $elseStart + $elseTagLength, $conditionEnd - $elseStart - $elseTagLength); + + return $elseBlock; + } + + /** + * Parse nested if conditions. + * @param string $string The string to parse + * @param array $arguments Array with the arguments (variables) from the template + * @param int $searchOffset Offset to start searching for the condition from + * @param int $replaceOffset Offset to start replacing the condition from + * @return string The parsed string + */ + protected static function parseNestedIfs(string $string, array $arguments = [], int $searchOffset = 0, int $replaceOffset = 0): string + { + $nestedIfs = preg_match_all(static::CONDITION_PATTERN, $string, $nestedMatches, PREG_OFFSET_CAPTURE, $searchOffset); + if ($nestedIfs !== false && $nestedIfs > 0) { + $string = static::parseConditions($string, $arguments, $replaceOffset); + } - if (!$default) { + return $string; + } + + /** + * @param string $defaultMatch The default value match + * @return mixed The default value + */ + protected static function getDefaultValue(string $defaultMatch): mixed + { + $default = ltrim($defaultMatch, '=') ?? null; + + if ($default === null) { return static::LITERALLY_NULL; } - if (is_numeric($default) && !preg_match('/([\'"`])/', $default)) { - return strpos($default, '.') === false ? (int)$default : (float)$default; - } else if ((str_starts_with($default, '"') && str_ends_with($default, '"')) || (str_starts_with($default, "'") && str_ends_with($default, "'"))) { - return (string) trim($default, '\'"`'); - } else if (in_array($default, ['false', 'true'])) { - return (bool) $default; - } else if ($default === 'null') { - return null; - } else { - return $default; + return static::convertArgumentType($default); + } + + private static function removeDuplicates(array $array): array + { + $uniqueValues = []; + foreach ($array as $value) { + if (in_array($value, $uniqueValues)) { + $value = null; + } else { + $uniqueValues[] = $value; + } } + return $uniqueValues; } } diff --git a/tests/Parsem/ParserTest.php b/tests/Parsem/ParserTest.php index 964c12d..4e8db51 100644 --- a/tests/Parsem/ParserTest.php +++ b/tests/Parsem/ParserTest.php @@ -70,6 +70,30 @@ public function testDefaultValue() Assert::equal('Hello world!', $parsed, 'Default value parsed correctly.'); } + /** @testCase */ + public function testDefaultValueTypes() + { + $string = 'Hello <% var="world" %><% var2=1 %><% var3=true %><% var4=null %>'; + $args = []; + + $parsed = Parser::parseString($string, $args); + + Assert::equal('Hello world11', $parsed, 'Default values parsed correctly.'); + } + + /** @testCase */ + public function testEmptyDefaultValue() + { + $string = 'Hello <% var= %>!'; + $string2 = 'Hello <% var=|upper %>!'; + + $parsed = Parser::parseString($string, []); + $parsed2 = Parser::parseString($string2, []); + + Assert::equal('Hello !', $parsed, 'Empty default value parsed correctly.'); + Assert::equal('Hello !', $parsed2, 'Empty default value with filter parsed correctly.'); + } + /** @testCase */ public function testIgnoreDefaultValue() { @@ -197,11 +221,9 @@ public function testNewLines() public function testElseBlocks() { $string = "Hello<% if false %> Amazing<% else %> Cruel<% endif %> World!"; - $expected = "Hello Cruel World!"; $string2 = "Hello<% if true %> Amazing<% else %> Cruel<% endif %> World!"; - $expected2 = "Hello Amazing World!"; $parsed = Parser::parseString($string, []); @@ -209,6 +231,120 @@ public function testElseBlocks() Assert::equal($expected, $parsed, '(false) Else block is parsed.'); Assert::equal($expected2, $parsed2, '(true) If block is parsed.'); } + + /** @testCase */ + public function testNestedIfElse() + { + $string = "Hello<% if false %> Amazing<% else %> Cruel<% if true %> World!<% endif %><% endif %>"; + $expected = "Hello Cruel World!"; + + $string2 = "Hello<% if true %> Amazing<% else %> Cruel<% if false %> World!<% endif %><% endif %>"; + $expected2 = "Hello Amazing"; + + $parsed = Parser::parseString($string, []); + $parsed2 = Parser::parseString($string2, []); + Assert::equal($expected, $parsed, '(false) Nested if-else block is parsed.'); + Assert::equal($expected2, $parsed2, '(true) Nested if-else block is parsed.'); + } + + /** @testCase */ + public function testDoubleNestedIfElseElse() + { + $string = "Hello<% if false %> Amazing<% else %> Cruel<% if false %> World!<% else %> Universe!<% endif %><% endif %>"; + $expected = "Hello Cruel Universe!"; + + $string2 = "Hello<% if true %> Amazing<% else %> Cruel<% if false %> World!<% else %> Universe!<% endif %><% endif %>"; + $expected2 = "Hello Amazing"; + + $string3 = "Hello<% if false %> Amazing<% else %> Cruel<% if true %> World!<% else %> Universe!<% endif %><% endif %>"; + $expected3 = "Hello Cruel World!"; + + $parsed = Parser::parseString($string, []); + $parsed2 = Parser::parseString($string2, []); + $parsed3 = Parser::parseString($string3, []); + Assert::equal($expected, $parsed, '(false false) Double nested if-else block is parsed.'); + Assert::equal($expected2, $parsed2, '(true false) Double nested if-else block is parsed.'); + Assert::equal($expected3, $parsed3, '(false true) Double nested if-else block is parsed.'); + } + + /** @testCase */ + public function testDoubleNestedIfElseElseElse() + { + $string = "Hello<% if false %> Amazing<% else %> Cruel<% if false %> World<% else %> Universe<% endif %><% if false %>!<% else %>?<% endif %><% endif %>"; + $expected = "Hello Cruel Universe?"; + + $string2 = "Hello<% if false %> Amazing<% else %> Cruel<% if true %> World<% else %> Universe<% endif %><% if false %>!<% else %>?<% endif %><% endif %>"; + + $expected2 = "Hello Cruel World?"; + + $string3 = "Hello<% if false %> Amazing<% else %> Cruel<% if true %> World!<% else %> Universe<% if false %> Milky Way!<% else %> Andromeda!<% endif %><% endif %><% endif %>"; + $expected3 = "Hello Cruel World!"; + + $string4 = "Hello<% if false %> Amazing<% else %> Cruel<% if false %> World!<% else %> Universe<% if true %> Milky Way!<% else %> Andromeda!<% endif %><% endif %><% endif %>"; + $expected4 = "Hello Cruel Universe Milky Way!"; + + $parsed = Parser::parseString($string, []); + $parsed2 = Parser::parseString($string2, []); + $parsed3 = Parser::parseString($string3, []); + $parsed4 = Parser::parseString($string4, []); + Assert::equal($expected, $parsed, '(false false false) Double nested if-else block is parsed.'); + Assert::equal($expected2, $parsed2, '(false true false) Double nested if-else block is parsed.'); + Assert::equal($expected3, $parsed3, '(false true false) Double nested if-else block is parsed.'); + Assert::equal($expected4, $parsed4, '(false false true) Double nested if-else block is parsed.'); + } + + /** @testCase */ + public function testEmptyIfBlockAndEmptyElseBlock() + { + $string = "Hello<% if false %><% else %> Cruel<% endif %> World!"; + $expected = "Hello Cruel World!"; + + $string2 = "Hello<% if true %> Amazing<% else %><% endif %> World!"; + $expected2 = "Hello Amazing World!"; + + $string3 = "Hello<% if true %><% else %> Cruel<% endif %> World!"; + $expected3 = "Hello World!"; + + $string4 = "Hello<% if false %> Amazing<% else %><% endif %> World!"; + $expected4 = "Hello World!"; + + $parsed = Parser::parseString($string, []); + $parsed2 = Parser::parseString($string2, []); + $parsed3 = Parser::parseString($string3, []); + $parsed4 = Parser::parseString($string4, []); + Assert::equal($expected, $parsed, 'Empty if block with false is parsed.'); + Assert::equal($expected2, $parsed2, 'Empty else block with true is parsed.'); + Assert::equal($expected3, $parsed3, 'Empty if block with true is parsed.'); + Assert::equal($expected4, $parsed4, 'Empty else block with false is parsed.'); + } + + /** @testCase */ + public function testNegatedCondition() + { + $string = "Hello<% if !false %> Amazing<% endif %> World!"; + $expected = "Hello Amazing World!"; + $string2 = "Hello<% if !true %> Amazing<% else %> Cruel<% endif %> World!"; + $expected2 = "Hello Cruel World!"; + + $parsed = Parser::parseString($string, []); + $parsed2 = Parser::parseString($string2, []); + Assert::equal($expected, $parsed, 'Negated false is true.'); + Assert::equal($expected2, $parsed2, 'Negated true is false -> else shown.'); + } + + /** @testCase */ + public function testNegatedArgument() + { + $string = 'Hello<% if !$foo %> Amazing<% endif %> World!'; + $expected = "Hello Amazing World!"; + $string2 = 'Hello<% if !$foo %> Amazing<% else %> Cruel<% endif %> World!'; + $expected2 = "Hello Cruel World!"; + + $parsed = Parser::parseString($string, ['foo' => null]); + $parsed2 = Parser::parseString($string2, ['foo' => true]); + Assert::equal($expected, $parsed, 'Negated false is true.'); + Assert::equal($expected2, $parsed2, 'Negated true is false -> else shown.'); + } } (new ParserTest())->run();