diff --git a/CHANGELOG.md b/CHANGELOG.md
index a85672f..26898f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+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
+ - 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
- Revise `PrettyPrint` to output top-level strings without quotes
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/phpcs.xml.dist b/phpcs.xml.dist
index 4c32073..245618d 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -11,7 +11,7 @@
' . $start;
$end = $end . '';
@@ -160,7 +168,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 +176,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 +225,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 +233,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(
@@ -244,68 +254,7 @@ public function __invoke(...$args)
}
}
- echo $start . implode(' ', $parts) . $end;
+ echo $start . implode($sep, $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==
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
new file mode 100644
index 0000000..4db6f09
--- /dev/null
+++ b/tests/EnvTest.php
@@ -0,0 +1,71 @@
+ 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
+ {
+ Env::setCliOverride(false);
+ self::assertFalse(Env::isCli());
+ Env::setCliOverride(true);
+ self::assertTrue(Env::isCli());
+ 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/FormatterTest.php b/tests/FormatterTest.php
index 60f86c0..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;
@@ -194,4 +195,99 @@ 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' => [
+ [
+ [[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));
+ }
+
+ 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));
+ }
+
+ #[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/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');
+ }
}
diff --git a/tests/PrettyPrintTest.php b/tests/PrettyPrintTest.php
index aaed041..b0d8e63 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;
@@ -43,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
@@ -353,4 +376,72 @@ 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);
+ }
+ }
+
+ #[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);
+ }
}