From d86bf0b0fd6d40657941f78640e99eb706e046c2 Mon Sep 17 00:00:00 2001 From: Samuel Akopyan Date: Thu, 4 Dec 2025 17:02:51 +0200 Subject: [PATCH 1/8] Refactor `PrettyPrint` to delegate `format3DTorch` logic to `Formatter` for consistency; add and update tests in `FormatterTest`; increase line length limit in `phpcs.xml.dist` to 250. --- CHANGELOG.md | 4 +++ phpcs.xml.dist | 2 +- src/Formatter.php | 58 +++++++++++++++++++++++++++++++++ src/PrettyPrint.php | 71 ++++------------------------------------- tests/FormatterTest.php | 33 +++++++++++++++++++ 5 files changed, 103 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a85672f..1d53485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +0.4.2 / + - Refactor `PrettyPrint` to delegate `format3DTorch` logic to `Formatter` for consistency + - Add and update tests in `FormatterTest` + 0.4.1 / 2025-12-02 - Enhance `PrettyPrint::formatValue` to handle objects, resources, and unknown types - Revise `PrettyPrint` to output top-level strings without quotes diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 4c32073..245618d 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -11,7 +11,7 @@ - + diff --git a/src/Formatter.php b/src/Formatter.php index 5c4dedf..0283461 100644 --- a/src/Formatter.php +++ b/src/Formatter.php @@ -267,4 +267,62 @@ public static function formatCell(mixed $cell, int $precision): string } return $s; } + + /** + * Format a 3D numeric tensor in a PyTorch-like multiline representation. + * + * @param array $tensor3d 3D array of ints/floats. + * @param int $headB Number of head 2D slices to display. + * @param int $tailB Number of tail 2D slices to display. + * @param int $headRows Number of head rows per 2D slice. + * @param int $tailRows Number of tail rows per 2D slice. + * @param int $headCols Number of head columns per 2D slice. + * @param int $tailCols Number of tail columns per 2D slice. + * @param string $label Prefix label used instead of "tensor". + * @param int $precision + * @return string + */ + public static function format3DTorch(array $tensor3d, int $headB = 5, int $tailB = 5, int $headRows = 5, int $tailRows = 5, int $headCols = 5, int $tailCols = 5, string $label = 'tensor', int $precision = 2): string + { + $B = count($tensor3d); + $idxs = []; + $useBEllipsis = false; + if ($B <= $headB + $tailB) { + for ($i = 0; $i < $B; $i++) { + $idxs[] = $i; + } + } else { + for ($i = 0; $i < $headB; $i++) { + $idxs[] = $i; + } + $useBEllipsis = true; + for ($i = $B - $tailB; $i < $B; $i++) { + $idxs[] = $i; + } + } + + $blocks = []; + $format2d = function ($matrix) use ($headRows, $tailRows, $headCols, $tailCols, $precision) { + return self::format2DSummarized($matrix, $headRows, $tailRows, $headCols, $tailCols, $precision); + }; + + $limitHead = ($B <= $headB + $tailB) ? count($idxs) : $headB; + for ($i = 0; $i < $limitHead; $i++) { + $formatted2d = $format2d($tensor3d[$idxs[$i]]); + // Indent entire block by a single space efficiently + $blocks[] = ' ' . str_replace("\n", "\n ", $formatted2d); + } + if ($useBEllipsis) { + $blocks[] = ' ...'; + } + if ($useBEllipsis) { + for ($i = $limitHead; $i < count($idxs); $i++) { + $formatted2d = $format2d($tensor3d[$idxs[$i]]); + $blocks[] = ' ' . str_replace("\n", "\n ", $formatted2d); + } + } + + $joined = implode(",\n\n ", $blocks); + return $label . "([\n " . $joined . "\n])"; + } } diff --git a/src/PrettyPrint.php b/src/PrettyPrint.php index f098e1d..d222c9c 100644 --- a/src/PrettyPrint.php +++ b/src/PrettyPrint.php @@ -160,7 +160,7 @@ public function __invoke(...$args) // Label + single 3D tensor if (count($args) === 2 && !is_array($args[0]) && is_array($args[1]) && Validator::is3D($args[1])) { - $out = $this->format3DTorch( + $out = Formatter::format3DTorch( $args[1], (int)($fmt['headB'] ?? 5), (int)($fmt['tailB'] ?? 5), @@ -168,7 +168,8 @@ public function __invoke(...$args) (int)($fmt['tailRows'] ?? 5), (int)($fmt['headCols'] ?? 5), (int)($fmt['tailCols'] ?? 5), - (string)($fmt['label'] ?? 'tensor') + (string)($fmt['label'] ?? 'tensor'), + $this->precision ); echo $start . $out . $end; $this->precision = $prevPrecision; @@ -216,7 +217,7 @@ public function __invoke(...$args) foreach ($args as $arg) { if (is_array($arg)) { if (Validator::is3D($arg)) { - $parts[] = $this->format3DTorch( + $parts[] = Formatter::format3DTorch( $arg, (int)($fmt['headB'] ?? 5), (int)($fmt['tailB'] ?? 5), @@ -224,7 +225,8 @@ public function __invoke(...$args) (int)($fmt['tailRows'] ?? 5), (int)($fmt['headCols'] ?? 5), (int)($fmt['tailCols'] ?? 5), - (string)($fmt['label'] ?? 'tensor') + (string)($fmt['label'] ?? 'tensor'), + $this->precision ); } elseif (Validator::is2D($arg)) { $parts[] = Formatter::format2DTorch( @@ -247,65 +249,6 @@ public function __invoke(...$args) echo $start . implode(' ', $parts) . $end; $this->precision = $prevPrecision; } - - // ---- Private helpers ---- - - /** - * Format a 3D numeric tensor in a PyTorch-like multiline representation. - * - * @param array $tensor3d 3D array of ints/floats. - * @param int $headB Number of head 2D slices to display. - * @param int $tailB Number of tail 2D slices to display. - * @param int $headRows Number of head rows per 2D slice. - * @param int $tailRows Number of tail rows per 2D slice. - * @param int $headCols Number of head columns per 2D slice. - * @param int $tailCols Number of tail columns per 2D slice. - * @param string $label Prefix label used instead of "tensor". - * @return string - */ - private function format3DTorch(array $tensor3d, int $headB = 5, int $tailB = 5, int $headRows = 5, int $tailRows = 5, int $headCols = 5, int $tailCols = 5, string $label = 'tensor'): string - { - $B = count($tensor3d); - $idxs = []; - $useBEllipsis = false; - if ($B <= $headB + $tailB) { - for ($i = 0; $i < $B; $i++) { - $idxs[] = $i; - } - } else { - for ($i = 0; $i < $headB; $i++) { - $idxs[] = $i; - } - $useBEllipsis = true; - for ($i = $B - $tailB; $i < $B; $i++) { - $idxs[] = $i; - } - } - - $blocks = []; - $format2d = function ($matrix) use ($headRows, $tailRows, $headCols, $tailCols) { - return Formatter::format2DSummarized($matrix, $headRows, $tailRows, $headCols, $tailCols, $this->precision); - }; - - $limitHead = ($B <= $headB + $tailB) ? count($idxs) : $headB; - for ($i = 0; $i < $limitHead; $i++) { - $formatted2d = $format2d($tensor3d[$idxs[$i]]); - // Indent entire block by a single space efficiently - $blocks[] = ' ' . str_replace("\n", "\n ", $formatted2d); - } - if ($useBEllipsis) { - $blocks[] = ' ...'; - } - if ($useBEllipsis) { - for ($i = $limitHead; $i < count($idxs); $i++) { - $formatted2d = $format2d($tensor3d[$idxs[$i]]); - $blocks[] = ' ' . str_replace("\n", "\n ", $formatted2d); - } - } - - $joined = implode(",\n\n ", $blocks); - return $label . "([\n " . $joined . "\n])"; - } } -// 672/605/499/312== +// 672/605/499/312/252== diff --git a/tests/FormatterTest.php b/tests/FormatterTest.php index 60f86c0..0ac9f92 100644 --- a/tests/FormatterTest.php +++ b/tests/FormatterTest.php @@ -194,4 +194,37 @@ public function testFormat2DTorch(array $matrix, int $headRows, int $tailRows, i { self::assertSame($expected, Formatter::format2DTorch($matrix, $headRows, $tailRows, $headCols, $tailCols, $label, $precision)); } + + public static function format3DTorchProvider(): array + { + return [ + 'two 2x2 blocks, no truncation' => [ + [[[1, 2], [3, 4]], [[5, 6], [7, 8]]], + 5, 5, 5, 5, 5, 5, + 'tensor', + 2, + "tensor([\n [[1, 2],\n [3, 4]],\n\n [[5, 6],\n [7, 8]]\n])", + ], + 'block ellipsis with inner 2D ellipses' => [ + // 3D: 3 blocks so headB=1, tailB=1 yields a middle ellipsis + [ + [[1, 2, 3], [4, 5, 6], [7, 8, 9]], + [[1, 2, 3], [4, 5, 6], [7, 8, 9]], + [[1, 2, 3], [4, 5, 6], [7, 8, 9]], + ], + 1, 1, 1, 1, 1, 1, + 'tensor', + 2, + "tensor([\n [[1, ..., 3],\n ...,\n [7, ..., 9]],\n\n ...,\n\n [[1, ..., 3],\n ...,\n [7, ..., 9]]\n])", + ], + ]; + } + + #[Test] + #[TestDox('format3DTorch wraps multiple 2D blocks with proper spacing, supports block ellipsis and inner 2D summarization')] + #[DataProvider('format3DTorchProvider')] + public function testFormat3DTorch(array $tensor3d, int $headB, int $tailB, int $headRows, int $tailRows, int $headCols, int $tailCols, string $label, int $precision, string $expected): void + { + self::assertSame($expected, Formatter::format3DTorch($tensor3d, $headB, $tailB, $headRows, $tailRows, $headCols, $tailCols, $label, $precision)); + } } From 4edbfb72cb5e12bd899add8ea8c40ab6188b86cc Mon Sep 17 00:00:00 2001 From: Samuel Akopyan Date: Thu, 4 Dec 2025 17:12:48 +0200 Subject: [PATCH 2/8] Unit tests for format3DTorch --- tests/FormatterTest.php | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/tests/FormatterTest.php b/tests/FormatterTest.php index 0ac9f92..dc9d671 100644 --- a/tests/FormatterTest.php +++ b/tests/FormatterTest.php @@ -206,7 +206,6 @@ public static function format3DTorchProvider(): array "tensor([\n [[1, 2],\n [3, 4]],\n\n [[5, 6],\n [7, 8]]\n])", ], 'block ellipsis with inner 2D ellipses' => [ - // 3D: 3 blocks so headB=1, tailB=1 yields a middle ellipsis [ [[1, 2, 3], [4, 5, 6], [7, 8, 9]], [[1, 2, 3], [4, 5, 6], [7, 8, 9]], @@ -227,4 +226,43 @@ public function testFormat3DTorch(array $tensor3d, int $headB, int $tailB, int $ { self::assertSame($expected, Formatter::format3DTorch($tensor3d, $headB, $tailB, $headRows, $tailRows, $headCols, $tailCols, $label, $precision)); } + + public static function formatForArrayProvider(): array + { + return [ + 'empty array' => [ + [], + 2, + '[]', + ], + '1D mixed scalars with precision for floats' => [ + [1, "a'b", true, null, 1.2], + 2, + "[1, a'b, True, None, 1.20]", + ], + 'nested non-2D arrays recurse' => [ + [[1, 2], 3, [4, [5.5]]], + 1, + '[[1, 2], 3, [4, [5.50]]]', + ], + '2D numeric uses aligned formatting' => [ + [[1, 23], [3, 4]], + 2, + "[[1, 23],\n [3, 4]]", + ], + '2D with mixed types falls back to recursive array formatting (not 2D)' => [ + [["a'b", false, (object)[]], [fopen('php://memory', 'r')]], + 2, + "[[a'b, False, Object], [Resource]]", + ], + ]; + } + + #[Test] + #[TestDox('formatForArray formats arrays recursively; 2D arrays use aligned formatting; scalars via formatCell')] + #[DataProvider('formatForArrayProvider')] + public function testFormatForArray(mixed $value, int $precision, string $expected): void + { + self::assertSame($expected, Formatter::formatForArray($value, $precision)); + } } From 1a924c8cda7bf28bab99f93a12c01c6349484fb3 Mon Sep 17 00:00:00 2001 From: Samuel Akopyan Date: Thu, 4 Dec 2025 17:23:39 +0200 Subject: [PATCH 3/8] Refactor `Formatter` and `PrettyPrint` to use `Env::isCli()` for CLI detection; introduce `Env` utility class with tests; update `FormatterTest` to validate new behavior. --- CHANGELOG.md | 2 ++ src/Env.php | 29 ++++++++++++++++++++ src/Formatter.php | 2 +- src/PrettyPrint.php | 2 +- tests/EnvTest.php | 49 +++++++++++++++++++++++++++++++++ tests/FormatterTest.php | 25 +++++++++++++++++ tests/PrettyPrintTest.php | 58 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 src/Env.php create mode 100644 tests/EnvTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d53485..5105e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ 0.4.2 / - Refactor `PrettyPrint` to delegate `format3DTorch` logic to `Formatter` for consistency - Add and update tests in `FormatterTest` + - Refactor `Formatter` and `PrettyPrint` to use `Env::isCli()` for CLI detection + - introduce `Env` utility class with tests; update `FormatterTest` to validate new behavior 0.4.1 / 2025-12-02 - Enhance `PrettyPrint::formatValue` to handle objects, resources, and unknown types diff --git a/src/Env.php b/src/Env.php new file mode 100644 index 0000000..ce0c8f5 --- /dev/null +++ b/src/Env.php @@ -0,0 +1,29 @@ + for web (non-CLI) usage - $isCli = (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg'); + $isCli = Env::isCli(); if (!$isCli) { $start = '
' . $start;
             $end = $end . '
'; diff --git a/tests/EnvTest.php b/tests/EnvTest.php new file mode 100644 index 0000000..7c0429f --- /dev/null +++ b/tests/EnvTest.php @@ -0,0 +1,49 @@ + true, otherwise false)')] + public function testIsCliMatchesCurrentSapi(): void + { + $expected = (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg'); + self::assertSame($expected, Env::isCli()); + } + + #[Test] + #[TestDox('isCli() returns a boolean')] + public function testIsCliReturnsBool(): void + { + self::assertIsBool(Env::isCli()); + } + + #[Test] + #[TestDox('setCliOverride() forces isCli() result and can be reset')] + public function testSetCliOverrideForcesAndResets(): void + { + // Force non-CLI + Env::setCliOverride(false); + self::assertFalse(Env::isCli()); + + // Force CLI + Env::setCliOverride(true); + self::assertTrue(Env::isCli()); + + // Reset to auto-detect + Env::setCliOverride(null); + $expected = (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg'); + self::assertSame($expected, Env::isCli()); + } +} diff --git a/tests/FormatterTest.php b/tests/FormatterTest.php index dc9d671..8fdb064 100644 --- a/tests/FormatterTest.php +++ b/tests/FormatterTest.php @@ -5,6 +5,7 @@ namespace Apphp\PrettyPrint\Tests; use Apphp\PrettyPrint\Formatter; +use Apphp\PrettyPrint\Env; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -265,4 +266,28 @@ public function testFormatForArray(mixed $value, int $precision, string $expecte { self::assertSame($expected, Formatter::formatForArray($value, $precision)); } + + #[Test] + #[TestDox('formatCell escapes strings with addslashes() when not CLI (web context)')] + public function testFormatCellEscapesInWebContext(): void + { + Env::setCliOverride(false); + try { + self::assertSame("a\\'b", Formatter::formatCell("a'b", 2)); + } finally { + Env::setCliOverride(null); + } + } + + #[Test] + #[TestDox('formatCell returns raw strings in CLI context')] + public function testFormatCellRawInCliContext(): void + { + Env::setCliOverride(true); + try { + self::assertSame("a'b", Formatter::formatCell("a'b", 2)); + } finally { + Env::setCliOverride(null); + } + } } diff --git a/tests/PrettyPrintTest.php b/tests/PrettyPrintTest.php index aaed041..1e3ee7c 100644 --- a/tests/PrettyPrintTest.php +++ b/tests/PrettyPrintTest.php @@ -5,6 +5,7 @@ namespace Apphp\PrettyPrint\Tests; use Apphp\PrettyPrint\PrettyPrint; +use Apphp\PrettyPrint\Env; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestDox; @@ -353,4 +354,61 @@ public function multipleRowsWithLabel(): void $out = ob_get_clean(); self::assertSame("Label\n[[1, 2],\n [3, 4]]\n", $out); } + + #[Test] + #[TestDox('limits the number of variadic arguments to MAX_ARGS (32)')] + public function limitsMaxArgs(): void + { + $pp = new PrettyPrint(); + $args = range(1, 40); + ob_start(); + $pp(...$args); + $out = ob_get_clean(); + $expected = implode(' ', array_map(static fn($n) => (string)$n, range(1, 32))) . "\n"; + self::assertSame($expected, $out); + } + + #[Test] + #[TestDox('removes unknown named arguments so they do not print as stray scalars')] + public function unknownNamedArgsAreRemoved(): void + { + $pp = new PrettyPrint(); + ob_start(); + // foo and baz are unknown named args and should be stripped + $pp('Hello', foo: 'bar', baz: 123); + $out = ob_get_clean(); + self::assertSame("Hello\n", $out); + } + + #[Test] + #[TestDox('auto-wraps output with
...
in non-CLI (web) context')] + public function autoWrapsPreInWebContext(): void + { + $pp = new PrettyPrint(); + Env::setCliOverride(false); + try { + ob_start(); + $pp('Hello', end: ''); + $out = ob_get_clean(); + self::assertSame('
Hello
', $out); + } finally { + Env::setCliOverride(null); + } + } + + #[Test] + #[TestDox('does not wrap with
 in CLI context')]
+    public function noPreWrapInCliContext(): void
+    {
+        $pp = new PrettyPrint();
+        Env::setCliOverride(true);
+        try {
+            ob_start();
+            $pp('Hello', end: '');
+            $out = ob_get_clean();
+            self::assertSame('Hello', $out);
+        } finally {
+            Env::setCliOverride(null);
+        }
+    }
 }

From 7492a1e5385e47623b4634a36af8be41f71a0c71 Mon Sep 17 00:00:00 2001
From: Samuel Akopyan 
Date: Thu, 4 Dec 2025 17:35:34 +0200
Subject: [PATCH 4/8] Add `Env::isTest()` method and corresponding tests;
 modify `ppd()` to skip exit in test mode; update PHPUnit config to set
 `APP_ENV=test`.

---
 phpunit.xml             |  3 +++
 src/Env.php             |  9 +++++++++
 src/functions.php       |  5 +++++
 tests/EnvTest.php       | 31 ++++++++++++++++++++++++++-----
 tests/FunctionsTest.php | 12 ++++++++++++
 5 files changed, 55 insertions(+), 5 deletions(-)

diff --git a/phpunit.xml b/phpunit.xml
index 7cc5a1d..6338af4 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -26,4 +26,7 @@
             
         
     
+    
+        
+    
 
diff --git a/src/Env.php b/src/Env.php
index ce0c8f5..a94c72a 100644
--- a/src/Env.php
+++ b/src/Env.php
@@ -22,6 +22,15 @@ public static function isCli(): bool
         return (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg');
     }
 
+    /**
+     * Check if the application is running in test mode.
+     * @return bool
+     */
+    public static function isTest(): bool
+    {
+        return (PHP_SAPI === 'cli' && getenv('APP_ENV') === 'test');
+    }
+
     public static function setCliOverride(?bool $value): void
     {
         self::$cliOverride = $value;
diff --git a/src/functions.php b/src/functions.php
index 21cf00b..9c3a46b 100644
--- a/src/functions.php
+++ b/src/functions.php
@@ -34,6 +34,11 @@ function pp(...$args): void
     function ppd(...$args): void
     {
         pprint(...$args);
+
+        if (PHP_SAPI === 'cli' && getenv('APP_ENV') === 'test') {
+            return;
+        }
+
         exit;
     }
 }
diff --git a/tests/EnvTest.php b/tests/EnvTest.php
index 7c0429f..0febb7d 100644
--- a/tests/EnvTest.php
+++ b/tests/EnvTest.php
@@ -33,17 +33,38 @@ public function testIsCliReturnsBool(): void
     #[TestDox('setCliOverride() forces isCli() result and can be reset')]
     public function testSetCliOverrideForcesAndResets(): void
     {
-        // Force non-CLI
         Env::setCliOverride(false);
         self::assertFalse(Env::isCli());
-
-        // Force CLI
         Env::setCliOverride(true);
         self::assertTrue(Env::isCli());
-
-        // Reset to auto-detect
         Env::setCliOverride(null);
         $expected = (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg');
         self::assertSame($expected, Env::isCli());
     }
+
+    #[Test]
+    #[TestDox('isTest() returns true when APP_ENV=test under CLI')]
+    public function testIsTestTrueWhenAppEnvTest(): void
+    {
+        $prev = getenv('APP_ENV');
+        try {
+            putenv('APP_ENV=test');
+            self::assertSame(PHP_SAPI === 'cli', Env::isTest());
+        } finally {
+            putenv('APP_ENV' . ($prev === false ? '' : '=' . $prev));
+        }
+    }
+
+    #[Test]
+    #[TestDox('isTest() returns false when APP_ENV is not test')]
+    public function testIsTestFalseWhenAppEnvNotTest(): void
+    {
+        $prev = getenv('APP_ENV');
+        try {
+            putenv('APP_ENV=dev');
+            self::assertFalse(Env::isTest());
+        } finally {
+            putenv('APP_ENV' . ($prev === false ? '' : '=' . $prev));
+        }
+    }
 }
diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php
index b932948..146b5e6 100644
--- a/tests/FunctionsTest.php
+++ b/tests/FunctionsTest.php
@@ -9,6 +9,8 @@
 use PHPUnit\Framework\Attributes\TestDox;
 use PHPUnit\Framework\Attributes\Group;
 use PHPUnit\Framework\Attributes\CoversFunction;
+use PHPUnit\Framework\Attributes\RunInSeparateProcess;
+use PHPUnit\Framework\Attributes\PreserveGlobalState;
 
 #[Group('prettyprint')]
 #[CoversFunction('pprint')]
@@ -35,4 +37,14 @@ public function ppIsAlias(): void
         $out = ob_get_clean();
         self::assertSame("1 2 3\n", $out);
     }
+
+    #[Test]
+    #[RunInSeparateProcess]
+    #[PreserveGlobalState(false)]
+    #[TestDox('global function ppd prints output (no exit in test env)')]
+    public function ppdPrintsThenExits(): void
+    {
+        $this->expectOutputString("hello\n");
+        ppd('hello');
+    }
 }

From 34f07fa9de0184e38a33ed9cfeeb11c02b3cb9e9 Mon Sep 17 00:00:00 2001
From: Samuel Akopyan 
Date: Thu, 4 Dec 2025 17:39:07 +0200
Subject: [PATCH 5/8] Add `Env::isTest()` method and corresponding tests;
 modify `ppd()` to skip exit in test mode; update PHPUnit config to set
 `APP_ENV=test`.

---
 tests/PrettyPrintTest.php | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/tests/PrettyPrintTest.php b/tests/PrettyPrintTest.php
index 1e3ee7c..97e2ad0 100644
--- a/tests/PrettyPrintTest.php
+++ b/tests/PrettyPrintTest.php
@@ -411,4 +411,15 @@ public function noPreWrapInCliContext(): void
             Env::setCliOverride(null);
         }
     }
+
+    #[Test]
+    #[TestDox('invokes PrettyPrint via explicit __invoke() method call')]
+    public function invokeMethodDirectly(): void
+    {
+        $pp = new PrettyPrint();
+        ob_start();
+        $pp->__invoke('Direct');
+        $out = ob_get_clean();
+        self::assertSame("Direct\n", $out);
+    }
 }

From 380835625607002eb65cc82b5b267e9af175fb90 Mon Sep 17 00:00:00 2001
From: Samuel Akopyan 
Date: Thu, 4 Dec 2025 17:40:20 +0200
Subject: [PATCH 6/8] Linter fixes

---
 tests/EnvTest.php         | 1 +
 tests/PrettyPrintTest.php | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/tests/EnvTest.php b/tests/EnvTest.php
index 0febb7d..4db6f09 100644
--- a/tests/EnvTest.php
+++ b/tests/EnvTest.php
@@ -2,6 +2,7 @@
 
 declare(strict_types=1);
 
+namespace Apphp\PrettyPrint\Tests;
 
 use Apphp\PrettyPrint\Env;
 use PHPUnit\Framework\Attributes\CoversClass;
diff --git a/tests/PrettyPrintTest.php b/tests/PrettyPrintTest.php
index 97e2ad0..b3d5f93 100644
--- a/tests/PrettyPrintTest.php
+++ b/tests/PrettyPrintTest.php
@@ -364,7 +364,7 @@ public function limitsMaxArgs(): void
         ob_start();
         $pp(...$args);
         $out = ob_get_clean();
-        $expected = implode(' ', array_map(static fn($n) => (string)$n, range(1, 32))) . "\n";
+        $expected = implode(' ', array_map(static fn ($n) => (string)$n, range(1, 32))) . "\n";
         self::assertSame($expected, $out);
     }
 

From 4e1ba87ab9d4182cb0c0e65068f4c863287cff85 Mon Sep 17 00:00:00 2001
From: Samuel Akopyan 
Date: Thu, 4 Dec 2025 17:54:12 +0200
Subject: [PATCH 7/8] Add support for custom separators in `PrettyPrint`;
 update tests to cover new functionality.

---
 CHANGELOG.md              |  1 +
 README.md                 | 11 +++++++++++
 src/PrettyPrint.php       | 12 ++++++++++--
 tests/PrettyPrintTest.php | 22 ++++++++++++++++++++++
 4 files changed, 44 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5105e6e..26898f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
  - Add and update tests in `FormatterTest`
  - Refactor `Formatter` and `PrettyPrint` to use `Env::isCli()` for CLI detection
  - introduce `Env` utility class with tests; update `FormatterTest` to validate new behavior
+ - Add support for custom separators in `PrettyPrint`; update tests to cover new functionality
 
 0.4.1 / 2025-12-02 
  - Enhance `PrettyPrint::formatValue` to handle objects, resources, and unknown types
diff --git a/README.md b/README.md
index 90e078c..7b6e013 100644
--- a/README.md
+++ b/README.md
@@ -110,6 +110,15 @@ pprint('Add 2 lines', end: "\n\n");
 pprint('Tabbed', start: "\t");
 // Combine with end to avoid newline
 pprint('Prompted', start: '>>> ', end: '');
+
+// Custom separator between multiple values (default is a single space " ")
+pprint('A', 'B', 'C', sep: ', ', end: '');
+// A, B, C
+
+// Separator can also be provided via trailing options array
+pprint('X', 'Y', ['sep' => "\n", 'end' => '']);
+// X
+// Y
 ```
 
 Print and then exit the script
@@ -159,6 +168,7 @@ Notes:
 
 - **start**: string. Prefix printed before the content. Example: `pprint('Hello', ['start' => "\t"])`.
 - **end**: string. Line terminator, default to new line. Example: `pprint('no newline', ['end' => '']);`
+- **sep**: string. Separator between multiple default-formatted arguments. Default is a single space `' '`. Examples: `pprint('A','B','C', sep: ', ', end: '')` or `pprint('X','Y', ['sep' => "\n", 'end' => ''])`.
 - **label**: string. Prefix label for 2D/3D formatted arrays, default `tensor`. Example: `pprint($m, ['label' => 'arr'])`.
 - **precision**: int. Number of digits after the decimal point for floats. Example: `pprint(3.14159, precision: 2)` prints `3.14`.
 - **headB / tailB**: ints. Number of head/tail 2D blocks shown for 3D tensors.
@@ -171,6 +181,7 @@ All options can be passed as:
 
 #### Defaults
 - **label**: `tensor`
+- **sep**: `' '`
 - **precision**: `4`
 - **headB / tailB**: `5`
 - **headRows / tailRows**: `5`
diff --git a/src/PrettyPrint.php b/src/PrettyPrint.php
index adb997a..8bdbe10 100644
--- a/src/PrettyPrint.php
+++ b/src/PrettyPrint.php
@@ -68,6 +68,7 @@ public function __invoke(...$args)
     {
         $end = PHP_EOL;
         $start = '';
+        $sep = ' ';
 
         // Named args for simple options
         if (isset($args['end'])) {
@@ -78,6 +79,10 @@ public function __invoke(...$args)
             $start = (string)$args['start'];
             unset($args['start']);
         }
+        if (isset($args['sep'])) {
+            $sep = (string)$args['sep'];
+            unset($args['sep']);
+        }
 
         // Extract optional tensor formatting options from trailing options array
         $fmt = [];
@@ -94,7 +99,7 @@ public function __invoke(...$args)
             $last = end($args);
             if (is_array($last)) {
                 $hasOptions = false;
-                $optionKeys = array_merge(['end', 'start'], $fmtKeys);
+                $optionKeys = array_merge(['end', 'start', 'sep'], $fmtKeys);
                 foreach ($optionKeys as $k) {
                     if (array_key_exists($k, $last)) {
                         $hasOptions = true;
@@ -108,6 +113,9 @@ public function __invoke(...$args)
                     if (array_key_exists('start', $last)) {
                         $start = (string)$last['start'];
                     }
+                    if (array_key_exists('sep', $last)) {
+                        $sep = (string)$last['sep'];
+                    }
                     // Merge trailing array options (takes precedence over named if both provided)
                     $fmt = array_merge($fmt, $last);
                     array_pop($args);
@@ -246,7 +254,7 @@ public function __invoke(...$args)
             }
         }
 
-        echo $start . implode(' ', $parts) . $end;
+        echo $start . implode($sep, $parts) . $end;
         $this->precision = $prevPrecision;
     }
 }
diff --git a/tests/PrettyPrintTest.php b/tests/PrettyPrintTest.php
index b3d5f93..b0d8e63 100644
--- a/tests/PrettyPrintTest.php
+++ b/tests/PrettyPrintTest.php
@@ -44,6 +44,28 @@ public function scalarsAndStrings(): void
         self::assertSame("Hello 123 4.5600\n", $out);
     }
 
+    #[Test]
+    #[TestDox('supports custom separator via named argument (sep)')]
+    public function customSeparatorNamed(): void
+    {
+        $pp = new PrettyPrint();
+        ob_start();
+        $pp('A', 'B', 'C', sep: ', ', end: '');
+        $out = ob_get_clean();
+        self::assertSame('A, B, C', $out);
+    }
+
+    #[Test]
+    #[TestDox('supports custom separator via trailing options array (sep)')]
+    public function customSeparatorTrailingArray(): void
+    {
+        $pp = new PrettyPrint();
+        ob_start();
+        $pp('X', 'Y', ['sep' => "\n", 'end' => '']);
+        $out = ob_get_clean();
+        self::assertSame("X\nY", $out);
+    }
+
     #[Test]
     #[TestDox('accepts named formatting args for 2D (headRows/tailRows/headCols/tailCols)')]
     public function namedFormattingOptions2D(): void

From 795f67093a53072b2279dc70bf09189976a62b78 Mon Sep 17 00:00:00 2001
From: Samuel Akopyan 
Date: Thu, 4 Dec 2025 17:56:20 +0200
Subject: [PATCH 8/8] Cleanup code

---
 src/PrettyPrint.php | 2 --
 1 file changed, 2 deletions(-)

diff --git a/src/PrettyPrint.php b/src/PrettyPrint.php
index 8bdbe10..798c1c8 100644
--- a/src/PrettyPrint.php
+++ b/src/PrettyPrint.php
@@ -258,5 +258,3 @@ public function __invoke(...$args)
         $this->precision = $prevPrecision;
     }
 }
-
-// 672/605/499/312/252==