66/**
77 * Risma
88 * A lightweight and flexible string processing engine for PHP.
9- * eveloped by https://github.com/nabeghe.
9+ * developed by [ https://github.com/nabeghe](https://github.com/nabeghe) .
1010 */
1111class Risma
1212{
@@ -20,6 +20,16 @@ class Risma
2020 */
2121 protected array $ classes = [];
2222
23+ /**
24+ * @var int Maximum recursion depth to prevent infinite loops.
25+ */
26+ protected int $ maxDepth = 10 ;
27+
28+ /**
29+ * @var int Current recursion depth tracker.
30+ */
31+ protected int $ currentDepth = 0 ;
32+
2333 public function __construct ()
2434 {
2535 $ this ->defineDefaultFuncs ();
@@ -37,11 +47,19 @@ protected function defineDefaultFuncs(): void
3747 $ this ->funcs ['ok ' ] ??= function ($ value ) {
3848 return $ value ? '1 ' : '0 ' ;
3949 };
50+
51+ $ this ->funcs ['prepend ' ] ??= function ($ input , $ prefix ) {
52+ return $ prefix .$ input ;
53+ };
54+
55+ $ this ->funcs ['append ' ] ??= function ($ input , $ suffix ) {
56+ return $ input .$ suffix ;
57+ };
4058 }
4159
4260 /**
4361 * Register a custom function.
44- * * @param string $name The name used in the template.
62+ * @param string $name The name used in the template.
4563 * @param callable $callback The logic to execute.
4664 */
4765 public function addFunc (string $ name , callable $ callback ): void
@@ -51,7 +69,7 @@ public function addFunc(string $name, callable $callback): void
5169
5270 /**
5371 * Register a class to expose its methods to the engine.
54- * * @param string $className Full namespace of the class.
72+ * @param string $className Full namespace of the class.
5573 */
5674 public function addClass (string $ className ): void
5775 {
@@ -62,15 +80,21 @@ public function addClass(string $className): void
6280
6381 /**
6482 * Renders the template string by replacing placeholders.
65- * * @param string $text The raw string with placeholders like {var.func}.
83+ * Uses recursive regex to match nested braces correctly.
84+ * @param string $text The raw string with placeholders like {var.func}.
6685 * @param array $vars Key-value pairs of data.
6786 * @param bool $default If true, returns empty string for missing variables.
6887 * @return string The processed text.
6988 * @throws Exception
7089 */
7190 public function render (string $ text , array $ vars , bool $ default = true ): string
7291 {
73- $ pattern = '/(!?)\{\s*(.*?)\s*\}/s ' ;
92+ // Reset depth counter at the start of render
93+ $ this ->currentDepth = 0 ;
94+
95+ // Use recursive regex pattern to match balanced curly braces
96+ // (?1) refers to the first capturing group recursively
97+ $ pattern = '/(!?)\{\s*((?:[^{}]|(?R))*)\s*\}/ ' ;
7498
7599 return preg_replace_callback ($ pattern , function ($ matches ) use ($ vars , $ default ) {
76100 $ isEscaped = !empty ($ matches [1 ]);
@@ -80,9 +104,25 @@ public function render(string $text, array $vars, bool $default = true): string
80104 return '{ ' .$ content .'} ' ;
81105 }
82106
107+ // Check recursion depth
108+ $ this ->currentDepth ++;
109+ if ($ this ->currentDepth > $ this ->maxDepth ) {
110+ $ this ->currentDepth --;
111+ throw new Exception ("Maximum recursion depth exceeded. " );
112+ }
113+
83114 try {
84- return $ this ->processExpression ($ content , $ vars , $ default );
115+ // First recursively render any nested placeholders
116+ $ renderedContent = $ this ->render ($ content , $ vars , $ default );
117+
118+ // Then process the expression itself
119+ $ result = $ this ->processExpression ($ renderedContent , $ vars , $ default );
120+
121+ $ this ->currentDepth --;
122+ return $ result ;
85123 } catch (Throwable $ e ) {
124+ $ this ->currentDepth --;
125+
86126 // Modified logic: If default is false, re-throw the exception for PHPUnit
87127 if (!$ default ) {
88128 throw $ e ;
@@ -94,7 +134,7 @@ public function render(string $text, array $vars, bool $default = true): string
94134
95135 /**
96136 * Processes the content inside a single {} block.
97- * * @throws Exception
137+ * @throws Exception
98138 */
99139 protected function processExpression (string $ expression , array $ vars , bool $ default ): string
100140 {
@@ -135,7 +175,7 @@ protected function processExpression(string $expression, array $vars, bool $defa
135175
136176 /**
137177 * Executes a single function within the chain.
138- * * @param string $token The function part, e.g., "func1" or "func('arg')".
178+ * @param string $token The function part, e.g., "func1" or "func('arg')".
139179 * @param mixed $prevValue The value from the previous step in the chain.
140180 * @param bool $isFirstCall Whether this is the start of a direct @ call.
141181 * @throws Exception
@@ -179,7 +219,7 @@ protected function executeFunction(string $token, $prevValue, bool $isFirstCall)
179219
180220 /**
181221 * Resolves the function name to a callable (Custom -> Class -> Global).
182- * * @throws Exception
222+ * @throws Exception
183223 */
184224 protected function resolveCallback (string $ name ): callable
185225 {
0 commit comments