Skip to content

Commit d196c76

Browse files
committed
help() and debug() print plain text on the command line
xmpWrap() wrapped output in <xmp> whenever no Content-Type header was set, which is always true under CLI - so terminals and cron logs got literal <xmp> tags around every help() and debug() call. Now both short-circuit to plain output in terminals, checking PHP_SAPI plus two fallbacks (Windows console SESSIONNAME, missing SCRIPT_NAME) since some hosts' CGI builds misreport SAPI. Same fix in SmartNull::help(), which has its own wrap. Web responses unchanged. Matches SmartString 3.0.0.
1 parent 3bd8616 commit d196c76

5 files changed

Lines changed: 54 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
- Array-syntax deprecation notices suggest one replacement style across reads, writes, `isset()`, and `unset()`: `->key` and `->key = $value` for property-safe names, `->{0}` for integer keys, `->{'users.id'}` for other keys. Reads used to suggest `->get(0)` while existence checks suggested `->{0}`, so one `empty()` call printed two notices with different advice, and writes suggested the now-deprecated `->set()`. Null and `''` keys are the exception and suggest `->get('')` / `->set('', $value)` - the brace form is a fatal error for an empty property name.
3333
- `isset($array['key'])` and `empty($array['key'])` now follow `$onOffsetAccess` like reads, writes, and `unset()` - notice by default, exception in `'throw'` mode. Existence checks were the one silent form of the deprecated `[]` syntax; if `[]` support is removed in a future version, `isset()` on the object would silently return false instead of erroring, so these call sites need migrating with the rest. Property-syntax checks (`isset($array->key)`) are unaffected and stay signal-free. Internal existence checks now call `array_key_exists()` directly, removing two method calls from every `get()`.
3434
- `isset()`, `empty()`, and `??` treat a stored null as missing, matching plain PHP arrays: on a NULL column, `isset($row->field)` is now false and `$row->field ?? 'none'` returns `'none'`. Previously they answered "does the column exist", so in HTML mode `??` fallbacks never fired on NULL columns (the wrapped null echoed as `""`). Bracket syntax (`isset($row['field'])`) matches. Direct access is unchanged: `$row->field` still returns the stored null, wrapped in HTML mode, with no warning. Ask `$row->keys()->contains('field')` when you need "does the key exist, even if NULL". Note `??` substitutes its fallback before the library runs, so the fallback skips HTML encoding - use `->or()` for display fallbacks that carry user data. See UPGRADING.md.
35+
- `help()` and `debug()` print plain text on the command line instead of wrapping output in literal `<xmp>` tags. Terminal detection checks `PHP_SAPI` plus two fallbacks (Windows console `SESSIONNAME`, missing `SCRIPT_NAME`) because some hosts' CGI builds misreport SAPI. Web responses are unchanged. Matches SmartString.
3536
- `or404()` outputs `<html>` instead of `<html lang>` - an empty `lang` reads as an invalid value to accessibility checkers, and the message language is caller-supplied so it can't be declared. Matches SmartString.
3637
- `orDie()` and `or404()` now exit with status 1 instead of 0, so shell scripts and cron jobs see the failure. Output is unchanged. Matches SmartString.
3738
- Developer-mistake exceptions (bad types, wrong context, misuse) now throw `CallerException`, which reports your file and line instead of the library's internals - the same class SmartString uses. It extends `InvalidArgumentException`, so existing catch blocks keep working, except six throws that previously used `RuntimeException`: `load()` misuse (no handler, non-callable handler, bad or empty field name, called on a record set), `orRedirect()` after headers sent, and writing to a `SmartNull`. See UPGRADING.md. `orThrow()` still throws `RuntimeException` by contract.

src/SmartArrayBase.php

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,15 +1017,34 @@ private static function prettyPrintR(mixed $var, int $debugLevel = 0, int $depth
10171017
/**
10181018
* Wrap output in <xmp> tag if text/html and not called from a function that already added <xmp>
10191019
*/
1020-
private static function xmpWrap($output): string
1020+
private static function xmpWrap(string $output): string
10211021
{
1022-
$output = trim($output, "\n");
1023-
$headersList = implode("\n", headers_list());
1024-
$hasContentType = (bool)preg_match('|^\s*Content-Type:\s*|im', $headersList); // assume no content type will default to HTML
1025-
$isTextHtml = !$hasContentType || preg_match('|^\s*Content-Type:\s*text/html\b|im', $headersList); // match: text/html or ...;charset=utf-8
1022+
$output = trim($output, "\n");
1023+
$plain = "\n$output\n";
1024+
1025+
// terminals show <xmp> literally; CGI builds misreport SAPI on some hosts, so check more than PHP_SAPI
1026+
$inCli = PHP_SAPI === 'cli'
1027+
|| ($_SERVER['SESSIONNAME'] ?? '') === 'Console' // Windows console
1028+
|| empty($_SERVER['SCRIPT_NAME']); // only web servers set SCRIPT_NAME
1029+
if ($inCli) {
1030+
return $plain;
1031+
}
1032+
1033+
// non-HTML responses (json, plain text, etc.) stay unwrapped
1034+
$headersList = implode("\n", headers_list());
1035+
$hasContentType = (bool)preg_match('|^\s*Content-Type:\s*|im', $headersList); // assume no content type will default to html
1036+
$isTextHtml = !$hasContentType || preg_match('|^\s*Content-Type:\s*text/html\b|im', $headersList); // match: text/html or ...;charset=utf-8
1037+
if (!$isTextHtml) {
1038+
return $plain;
1039+
}
1040+
1041+
// showme() debug helper adds its own <xmp>
10261042
$backtraceFunctions = array_map('strtolower', array_column(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS), 'function'));
1027-
$wrapInXmp = $isTextHtml && !in_array('showme', $backtraceFunctions, true);
1028-
return $wrapInXmp ? "\n<xmp>\n$output\n</xmp>\n" : "\n$output\n";
1043+
if (in_array('showme', $backtraceFunctions, true)) {
1044+
return $plain;
1045+
}
1046+
1047+
return "\n<xmp>\n$output\n</xmp>\n";
10291048
}
10301049

10311050
/**

src/SmartNull.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,15 @@ public function help(): void
102102
__TEXT__;
103103

104104
// Wrap in <xmp> for readability when output is (or will default to) HTML - same rule
105-
// as SmartArrayBase::xmpWrap(): no Content-Type header means PHP sends its default text/html
105+
// as SmartArrayBase::xmpWrap(): skip on CLI (terminals show the tags literally), and
106+
// no Content-Type header means PHP sends its default text/html
107+
$inCli = PHP_SAPI === 'cli'
108+
|| ($_SERVER['SESSIONNAME'] ?? '') === 'Console' // Windows console
109+
|| empty($_SERVER['SCRIPT_NAME']); // only web servers set SCRIPT_NAME
106110
$headersList = implode("\n", headers_list());
107111
$isHtmlOutput = !preg_match('|^\s*Content-Type:\s*|im', $headersList)
108112
|| preg_match('|^\s*Content-Type:\s*text/html\b|im', $headersList);
109-
if ($isHtmlOutput) {
113+
if (!$inCli && $isHtmlOutput) {
110114
$output = "<xmp>$output</xmp>";
111115
}
112116
echo $output;

tests/Integration/DocsExamplesTest.php

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,8 @@ public function testReadmePrintRShowsNestedStructure(): void
546546
}
547547

548548
// help() prints the whole of src/help.txt, so the assertions are stable anchors
549-
// (wrapper, first heading, one section, last line) instead of the full text
549+
// (first heading, one section, last line) instead of the full text. Output is
550+
// plain under CLI; the <xmp> web wrap can't be simulated in-process
550551
public function testReadmeHelpPrintsTheMethodReference(): void
551552
{
552553
$users = SmartArrayHtml::new([['id' => 10]]);
@@ -555,11 +556,10 @@ public function testReadmeHelpPrintsTheMethodReference(): void
555556
$users->help();
556557
});
557558

558-
$this->assertStringStartsWith("\n<xmp>\n", $output);
559+
$this->assertStringNotContainsString('<xmp>', $output);
559560
$this->assertStringContainsString('SmartArray: Enhanced Arrays with Automatic HTML Encoding and Chainable Methods', $output);
560561
$this->assertStringContainsString('Sorting & Filtering', $output);
561562
$this->assertStringContainsString('For more details see SmartArray readme.md', $output);
562-
$this->assertStringEndsWith("</xmp>\n", $output);
563563
}
564564

565565
//endregion
@@ -918,12 +918,11 @@ public function testHelpTxtDebugShowsValuesAndMysqliMetadata(): void
918918
$rows->debug();
919919
});
920920

921-
$this->assertStringStartsWith("\n<xmp>\n", $output);
921+
$this->assertStringNotContainsString('<xmp>', $output, 'plain output under CLI');
922922
$this->assertStringContainsString('Values are returned **as-is** on access', $output);
923923
$this->assertStringContainsString("'id' => 1", $output);
924924
$this->assertStringContainsString('MySQLi Metadata [', $output);
925925
$this->assertStringContainsString("'affected_rows' => 3", $output);
926-
$this->assertStringEndsWith("</xmp>\n", $output);
927926
}
928927

929928
public function testHelpTxtDebugOnHtmlModeNamesTheMode(): void

tests/Unit/DebugTest.php

Lines changed: 17 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@
2929
* (testDebugPadsShortValuesSoLoadAnnotationsLineUp) pins the raw bytes,
3030
* padding included, using quoted strings the editor cannot touch.
3131
*
32-
* CLI limits: headers_list() is always empty under CLI, so xmpWrap() sees no
33-
* Content-Type, assumes HTML, and wraps. The unwrapped path is only reachable
34-
* from a global showme() function, which is covered in a subprocess.
32+
* CLI limits: xmpWrap() short-circuits to plain output under CLI (PHP_SAPI),
33+
* so every expectation here pins the unwrapped format. The <xmp>-wrapped web
34+
* path can't be simulated in-process.
3535
*/
3636
class DebugTest extends SmartArrayTestCase
3737
{
@@ -52,7 +52,6 @@ public function testDebugLevelZeroShowsHeaderAndUnquotedValues(string $class): v
5252

5353
$expected = <<<__TEXT__
5454
55-
<xmp>
5655
$header
5756
5857
[
@@ -64,7 +63,6 @@ public function testDebugLevelZeroShowsHeaderAndUnquotedValues(string $class): v
6463
'null' => null
6564
'isFirst' => Q
6665
]
67-
</xmp>
6866
6967
__TEXT__;
7068

@@ -79,7 +77,6 @@ public function testDebugLevelZeroIndentsNestedRows(): void
7977

8078
$expected = <<<'__TEXT__'
8179
82-
<xmp>
8380
Itools\SmartArray\SmartArray - Values are returned **as-is** on access (no extra encoding)
8481
8582
[
@@ -102,7 +99,6 @@ public function testDebugLevelZeroIndentsNestedRows(): void
10299
'isFirst' => Q
103100
]
104101
]
105-
</xmp>
106102

107103
__TEXT__;
108104

@@ -117,12 +113,10 @@ public function testDebugLevelZeroShowsEmptyArrayAsEmptyBrackets(): void
117113

118114
$expected = <<<'__TEXT__'
119115
120-
<xmp>
121116
Itools\SmartArray\SmartArray - Values are returned **as-is** on access (no extra encoding)
122117
123118
[
124119
]
125-
</xmp>
126120

127121
__TEXT__;
128122

@@ -140,14 +134,13 @@ public function testDebugPadsShortValuesSoLoadAnnotationsLineUp(): void
140134

141135
[, $output] = $this->captureOutput(fn() => $sa->debug());
142136

143-
$expected = "\n<xmp>\n"
137+
$expected = "\n"
144138
. "Itools\\SmartArray\\SmartArray - Values are returned **as-is** on access (no extra encoding)\n"
145139
. "\n"
146140
. "[\n"
147141
. " 'id' => 7 \n"
148142
. " 'name' => Amy \n"
149-
. "]\n"
150-
. "</xmp>\n";
143+
. "]\n";
151144

152145
$this->assertSame($expected, $output);
153146
}
@@ -160,14 +153,12 @@ public function testDebugAnnotatesOnlyKeysTheLoadHandlerSupports(): void
160153

161154
$expected = <<<'__TEXT__'
162155
163-
<xmp>
164156
Itools\SmartArray\SmartArray - Values are returned **as-is** on access (no extra encoding)
165157
166158
[
167159
'title' => Post
168160
'author_id' => 7 // ->load('author_id') for more
169161
]
170-
</xmp>
171162

172163
__TEXT__;
173164

@@ -198,7 +189,6 @@ public function testDebugLevelOneAddsTypesObjectIdsAndProperties(): void
198189

199190
$expected = <<<'__TEXT__'
200191
201-
<xmp>
202192
Itools\SmartArray\SmartArray - Values are returned **as-is** on access (no extra encoding)
203193
204194
[ // SmartArray #{id}, Root #{id} (self)
@@ -217,7 +207,6 @@ public function testDebugLevelOneAddsTypesObjectIdsAndProperties(): void
217207
],
218208
'root' => SmartArray #{id}
219209
]
220-
</xmp>
221210

222211
__TEXT__;
223212

@@ -237,7 +226,6 @@ public function testDebugLevelOneMarksNonRootArraysWithTheirRootId(): void
237226

238227
$expected = <<<'__TEXT__'
239228
240-
<xmp>
241229
Itools\SmartArray\SmartArray - Values are returned **as-is** on access (no extra encoding)
242230
243231
[ // SmartArray #{id}, Root #{rootId}
@@ -250,7 +238,6 @@ public function testDebugLevelOneMarksNonRootArraysWithTheirRootId(): void
250238
],
251239
'root' => SmartArray #{rootId}
252240
]
253-
</xmp>
254241

255242
__TEXT__;
256243

@@ -301,7 +288,6 @@ public function testDebugShowsQueryAboveDataAndMetadataBelow(): void
301288
// The query prints twice: indented as written above the data, whitespace-collapsed in the metadata block
302289
$expected = <<<'__TEXT__'
303290
304-
<xmp>
305291
Itools\SmartArray\SmartArray - Values are returned **as-is** on access (no extra encoding)
306292
307293
MySQL Query:
@@ -320,7 +306,6 @@ public function testDebugShowsQueryAboveDataAndMetadataBelow(): void
320306
'insert_id' => 0
321307
'baseTable' => users
322308
]
323-
</xmp>
324309

325310
__TEXT__;
326311

@@ -347,15 +332,14 @@ public function testDebugReturnsNull(): void
347332
[$result, $output] = $this->captureOutput(fn() => $sa->debug());
348333

349334
$this->assertNull($result, 'debug() is void: it echoes, it does not chain');
350-
$this->assertStringStartsWith("\n<xmp>\n", $output);
351-
$this->assertStringEndsWith("\n</xmp>\n", $output);
335+
$this->assertStringNotContainsString('<xmp>', $output, 'CLI output is plain - terminals show the tags literally');
352336
}
353337

354338
/**
355-
* xmpWrap() skips the tags when a global showme() is on the call stack, on
356-
* the assumption that showme() already wrapped the output. Only a global
357-
* function counts (the backtrace holds namespaced names), so this runs in a
358-
* subprocess: a test file in a namespace cannot declare one.
339+
* A global showme() wrapper is the common CMSB debug idiom; this pins that
340+
* debug() inside it produces clean plain output in a subprocess. (The
341+
* showme() skip inside xmpWrap() is web-only and unreachable from CLI
342+
* tests, so the wrapped path stays audited rather than asserted.)
359343
*/
360344
public function testDebugSkipsXmpWrapWhenCalledFromGlobalShowmeFunction(): void
361345
{
@@ -388,15 +372,15 @@ function showme(\$obj) { \$obj->debug(); }
388372
//endregion
389373
//region help()
390374

391-
public function testHelpEchoesHelpTxtWrappedInXmp(): void
375+
public function testHelpEchoesHelpTxtPlainOnCli(): void
392376
{
393377
$sa = SmartArray::new(['a' => 1]);
394378

395379
[$result, $output] = $this->captureOutput(fn() => $sa->help());
396380

397381
$helpText = file_get_contents(dirname(__DIR__, 2) . '/src/help.txt');
398382
$this->assertNull($result, 'help() is void');
399-
$this->assertSame("\n<xmp>\n" . trim($helpText, "\n") . "\n</xmp>\n", $output);
383+
$this->assertSame("\n" . trim($helpText, "\n") . "\n", $output);
400384
}
401385

402386
public function testHelpListsTheSectionsItPromises(): void
@@ -564,18 +548,17 @@ public function testSmartNullDebugInfoReturnsNullValue(): void
564548
}
565549

566550
/**
567-
* SmartNull::help() uses the same wrapping rule as SmartArray::help(): no
568-
* Content-Type header means PHP sends its default text/html, so wrap. Under
569-
* CLI headers_list() is empty, so the wrapped path is what this pins.
551+
* SmartNull::help() uses the same wrapping rule as SmartArray::help():
552+
* plain output on CLI, <xmp>-wrapped only for text/html web responses.
570553
*/
571-
public function testSmartNullHelpWrapsInXmpWhenNoContentTypeIsSet(): void
554+
public function testSmartNullHelpPrintsPlainOnCli(): void
572555
{
573556
$smartNull = SmartArray::new([])->first();
574557

575558
[$result, $output] = $this->captureOutput(fn() => $smartNull->help());
576559

577560
$expected = <<<'__TEXT__'
578-
<xmp>SmartNull - Chainable Null Object for Missing Elements
561+
SmartNull - Chainable Null Object for Missing Elements
579562
===================================================
580563
SmartNull is returned when accessing non-existent elements where the type
581564
(SmartArray or SmartString) is ambiguous.
@@ -584,7 +567,7 @@ public function testSmartNullHelpWrapsInXmpWhenNoContentTypeIsSet(): void
584567
are called, it delegates to either a new empty SmartArray or a null
585568
SmartString as appropriate. This allows unlimited method chaining
586569
without null checks, returning appropriate empty/null values when
587-
the final result is accessed.</xmp>
570+
the final result is accessed.
588571
__TEXT__;
589572

590573
$this->assertNull($result, 'help() is void');

0 commit comments

Comments
 (0)