Skip to content

Commit cc8ce9b

Browse files
committed
feat: implement watch history tracking and conditional request completion for DDLess
1 parent f3b21f5 commit cc8ce9b

3 files changed

Lines changed: 279 additions & 0 deletions

File tree

src/debug.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1196,6 +1196,54 @@ function ddless_step_check(string $file, int $line, string $relativePath, bool $
11961196
fflush($GLOBALS['__DDLESS_IPC_STREAM__']);
11971197
};
11981198

1199+
$watches = $GLOBALS['__DDLESS_WATCHES__'] ?? [];
1200+
if (!empty($watches)) {
1201+
if (!isset($GLOBALS['__DDLESS_WATCH_LAST__'])) {
1202+
$GLOBALS['__DDLESS_WATCH_LAST__'] = [];
1203+
}
1204+
$prevLoc = $GLOBALS['__DDLESS_WATCH_PREV_LOC__'] ?? null;
1205+
foreach ($watches as $watchExpr) {
1206+
if (preg_match('/^\$(\w+)/', $watchExpr, $m)) {
1207+
$rootName = $m[1];
1208+
if ($rootName !== 'this' && !array_key_exists($rootName, $scopeVariables)) {
1209+
continue;
1210+
}
1211+
if ($rootName === 'this') {
1212+
$hasThis = false;
1213+
foreach ($scopeBacktrace as $frame) {
1214+
$fn = $frame['function'] ?? '';
1215+
if (str_starts_with($fn, 'ddless_')) continue;
1216+
if (isset($frame['object']) && is_object($frame['object'])) { $hasThis = true; }
1217+
break;
1218+
}
1219+
if (!$hasThis) continue;
1220+
}
1221+
}
1222+
try {
1223+
$watchVal = ddless_eval_in_context($watchExpr, $scopeVariables, $scopeBacktrace);
1224+
$normalized = ddless_normalize_value($watchVal, 0);
1225+
$serialized = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
1226+
$isFirstCapture = !array_key_exists($watchExpr, $GLOBALS['__DDLESS_WATCH_LAST__']);
1227+
$lastSerialized = $GLOBALS['__DDLESS_WATCH_LAST__'][$watchExpr] ?? null;
1228+
if ($serialized !== $lastSerialized) {
1229+
$GLOBALS['__DDLESS_WATCH_LAST__'][$watchExpr] = $serialized;
1230+
$useLoc = (!$isFirstCapture && $prevLoc !== null) ? $prevLoc : ['file' => $relativePath, 'line' => $line];
1231+
$historyPayload = json_encode([
1232+
'expr' => $watchExpr,
1233+
'value' => $normalized,
1234+
'file' => $useLoc['file'],
1235+
'line' => $useLoc['line'],
1236+
'timestamp' => microtime(true),
1237+
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
1238+
fwrite($GLOBALS['__DDLESS_IPC_STREAM__'], "__DDLESS_WATCH_HISTORY__:{$historyPayload}\n");
1239+
fflush($GLOBALS['__DDLESS_IPC_STREAM__']);
1240+
}
1241+
} catch (\Throwable $watchErr) {
1242+
}
1243+
}
1244+
$GLOBALS['__DDLESS_WATCH_PREV_LOC__'] = ['file' => $relativePath, 'line' => $line];
1245+
}
1246+
11991247
if ($dumppointExpressions !== '' && $isUserBreakpoint) {
12001248
$expressions = array_filter(explode("\n", $dumppointExpressions), fn($e) => trim($e) !== '');
12011249
$results = [];
@@ -1212,6 +1260,9 @@ function ddless_step_check(string $file, int $line, string $relativePath, bool $
12121260
'file' => $relativePath, 'line' => $line, 'results' => $results,
12131261
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
12141262
fwrite($GLOBALS['__DDLESS_IPC_STREAM__'], "__DDLESS_DUMPPOINT__:{$payload}\n");
1263+
if (php_sapi_name() === 'cli-server') {
1264+
fwrite($GLOBALS['__DDLESS_IPC_STREAM__'], "__DDLESS_REQUEST_COMPLETE__\n");
1265+
}
12151266
fflush($GLOBALS['__DDLESS_IPC_STREAM__']);
12161267
exit(0);
12171268
}
@@ -1242,6 +1293,9 @@ function ddless_step_check(string $file, int $line, string $relativePath, bool $
12421293
'file' => $relativePath, 'line' => $line, 'results' => $results,
12431294
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
12441295
fwrite($GLOBALS['__DDLESS_IPC_STREAM__'], "__DDLESS_DUMPPOINT__:{$payload}\n");
1296+
if (php_sapi_name() === 'cli-server') {
1297+
fwrite($GLOBALS['__DDLESS_IPC_STREAM__'], "__DDLESS_REQUEST_COMPLETE__\n");
1298+
}
12451299
fflush($GLOBALS['__DDLESS_IPC_STREAM__']);
12461300
exit(0);
12471301
}

tests/WatchHistoryTest.php

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
<?php
2+
/**
3+
* Tests for watch history tracking in ddless_step_check.
4+
* Run: php tests/php/WatchHistoryTest.php
5+
*/
6+
require_once __DIR__ . '/bootstrap.php';
7+
8+
/**
9+
* Capture watch history markers emitted during step_check calls.
10+
* Returns an array of decoded history entries in order.
11+
*/
12+
function capture_watch_history(callable $fn): array
13+
{
14+
$memStream = fopen('php://memory', 'w+');
15+
$originalStream = $GLOBALS['__DDLESS_IPC_STREAM__'] ?? null;
16+
$GLOBALS['__DDLESS_IPC_STREAM__'] = $memStream;
17+
18+
try {
19+
$fn();
20+
} finally {
21+
$GLOBALS['__DDLESS_IPC_STREAM__'] = $originalStream;
22+
}
23+
24+
rewind($memStream);
25+
$output = stream_get_contents($memStream);
26+
fclose($memStream);
27+
28+
$entries = [];
29+
foreach (preg_split("/\r?\n/", $output) as $line) {
30+
if (strpos($line, '__DDLESS_WATCH_HISTORY__:') === 0) {
31+
$json = substr($line, strlen('__DDLESS_WATCH_HISTORY__:'));
32+
$decoded = json_decode($json, true);
33+
if (is_array($decoded)) $entries[] = $decoded;
34+
}
35+
}
36+
return $entries;
37+
}
38+
39+
/**
40+
* Reset watch-related globals to a clean state.
41+
*/
42+
function reset_watch_state(array $watches = []): void
43+
{
44+
$GLOBALS['__DDLESS_WATCHES__'] = $watches;
45+
$GLOBALS['__DDLESS_WATCH_LAST__'] = [];
46+
$GLOBALS['__DDLESS_WATCH_PREV_LOC__'] = null;
47+
$GLOBALS['__DDLESS_HIT_USER_BPS__'] = [];
48+
$GLOBALS['__DDLESS_STEP_IN_MODE__'] = false;
49+
$GLOBALS['__DDLESS_STEP_OVER_FUNCTION__'] = null;
50+
$GLOBALS['__DDLESS_STEP_OUT_TARGET__'] = null;
51+
}
52+
53+
function fake_backtrace(): array
54+
{
55+
return [
56+
['function' => 'test', 'file' => '/app/TestFile.php', 'line' => 10],
57+
];
58+
}
59+
60+
// ============================================================================
61+
62+
section('Watch history: basic value change detection');
63+
64+
test('records the initial value on first capture', function () {
65+
reset_watch_state(['$pointOfSaleId']);
66+
$entries = capture_watch_history(function () {
67+
ddless_step_check(
68+
'/app/TestFile.php', 20, 'app/TestFile.php', false,
69+
'', ['pointOfSaleId' => 6252], fake_backtrace(),
70+
'', '', '', ''
71+
);
72+
});
73+
74+
assert_count(1, $entries, 'should emit one entry for first capture');
75+
assert_eq(6252, $entries[0]['value'], 'value should be 6252');
76+
assert_eq(20, $entries[0]['line'], 'first capture uses current line');
77+
});
78+
79+
test('does not emit when value is unchanged', function () {
80+
reset_watch_state(['$pointOfSaleId']);
81+
$entries = capture_watch_history(function () {
82+
ddless_step_check('/app/TestFile.php', 20, 'app/TestFile.php', false, '', ['pointOfSaleId' => 6252], fake_backtrace(), '', '', '', '');
83+
ddless_step_check('/app/TestFile.php', 21, 'app/TestFile.php', false, '', ['pointOfSaleId' => 6252], fake_backtrace(), '', '', '', '');
84+
ddless_step_check('/app/TestFile.php', 22, 'app/TestFile.php', false, '', ['pointOfSaleId' => 6252], fake_backtrace(), '', '', '', '');
85+
});
86+
87+
assert_count(1, $entries, 'should only emit once — value never changed');
88+
});
89+
90+
test('emits a new entry when value changes', function () {
91+
reset_watch_state(['$status']);
92+
$entries = capture_watch_history(function () {
93+
ddless_step_check('/app/Order.php', 10, 'app/Order.php', false, '', ['status' => 'pending'], fake_backtrace(), '', '', '', '');
94+
ddless_step_check('/app/Order.php', 15, 'app/Order.php', false, '', ['status' => 'approved'], fake_backtrace(), '', '', '', '');
95+
});
96+
97+
assert_count(2, $entries);
98+
assert_eq('pending', $entries[0]['value']);
99+
assert_eq('approved', $entries[1]['value']);
100+
});
101+
102+
// ============================================================================
103+
104+
section('Watch history: step_check is injected BEFORE line, so changes map to previous location');
105+
106+
test('change is attributed to the previous step_check location', function () {
107+
reset_watch_state(['$x']);
108+
$entries = capture_watch_history(function () {
109+
ddless_step_check('/app/A.php', 10, 'app/A.php', false, '', ['x' => 5], fake_backtrace(), '', '', '', '');
110+
ddless_step_check('/app/A.php', 11, 'app/A.php', false, '', ['x' => 10], fake_backtrace(), '', '', '', '');
111+
});
112+
113+
assert_count(2, $entries);
114+
assert_eq(10, $entries[0]['line'], 'first capture uses its own line');
115+
assert_eq(10, $entries[1]['line'], 'subsequent change uses the PREVIOUS step_check line (where assignment happened)');
116+
assert_eq(10, $entries[1]['value'], 'value captured at line 11 reflects new value');
117+
});
118+
119+
test('change crossing files uses the previous file', function () {
120+
reset_watch_state(['$x']);
121+
$entries = capture_watch_history(function () {
122+
ddless_step_check('/app/A.php', 10, 'app/A.php', false, '', ['x' => 1], fake_backtrace(), '', '', '', '');
123+
ddless_step_check('/app/B.php', 5, 'app/B.php', false, '', ['x' => 2], fake_backtrace(), '', '', '', '');
124+
});
125+
126+
assert_count(2, $entries);
127+
assert_eq('app/A.php', $entries[0]['file']);
128+
assert_eq('app/A.php', $entries[1]['file'], 'change at B.php:5 attributed to A.php (previous step_check)');
129+
assert_eq(10, $entries[1]['line']);
130+
});
131+
132+
// ============================================================================
133+
134+
section('Watch history: skips when root variable not in scope');
135+
136+
test('silently skips when variable does not exist (closure without use)', function () {
137+
reset_watch_state(['$pointOfSaleId']);
138+
$entries = capture_watch_history(function () {
139+
ddless_step_check('/app/A.php', 10, 'app/A.php', false, '', ['pointOfSaleId' => 6252], fake_backtrace(), '', '', '', '');
140+
ddless_step_check('/app/A.php', 20, 'app/A.php', false, '', ['q' => 'closure_scope'], fake_backtrace(), '', '', '', '');
141+
ddless_step_check('/app/A.php', 30, 'app/A.php', false, '', ['pointOfSaleId' => 6252], fake_backtrace(), '', '', '', '');
142+
});
143+
144+
assert_count(1, $entries, 'should emit only the first capture — scope without variable is skipped');
145+
assert_eq(6252, $entries[0]['value']);
146+
});
147+
148+
test('re-emits when variable reappears with a different value', function () {
149+
reset_watch_state(['$x']);
150+
$entries = capture_watch_history(function () {
151+
ddless_step_check('/app/A.php', 10, 'app/A.php', false, '', ['x' => 100], fake_backtrace(), '', '', '', '');
152+
ddless_step_check('/app/A.php', 20, 'app/A.php', false, '', ['other' => 'nope'], fake_backtrace(), '', '', '', '');
153+
ddless_step_check('/app/A.php', 30, 'app/A.php', false, '', ['x' => 200], fake_backtrace(), '', '', '', '');
154+
});
155+
156+
assert_count(2, $entries);
157+
assert_eq(100, $entries[0]['value']);
158+
assert_eq(200, $entries[1]['value']);
159+
});
160+
161+
// ============================================================================
162+
163+
section('Watch history: nested property access');
164+
165+
test('tracks $user->id when $user exists', function () {
166+
reset_watch_state(['$user->id']);
167+
$user = new stdClass();
168+
$user->id = 42;
169+
$entries = capture_watch_history(function () use ($user) {
170+
ddless_step_check('/app/A.php', 10, 'app/A.php', false, '', ['user' => $user], fake_backtrace(), '', '', '', '');
171+
});
172+
173+
assert_count(1, $entries);
174+
assert_eq(42, $entries[0]['value']);
175+
});
176+
177+
test('skips $user->id when $user is not in scope', function () {
178+
reset_watch_state(['$user->id']);
179+
$entries = capture_watch_history(function () {
180+
ddless_step_check('/app/A.php', 10, 'app/A.php', false, '', ['other' => 'x'], fake_backtrace(), '', '', '', '');
181+
});
182+
183+
assert_count(0, $entries, 'no $user in scope → skip');
184+
});
185+
186+
// ============================================================================
187+
188+
section('Watch history: multiple watches');
189+
190+
test('tracks multiple watch expressions independently', function () {
191+
reset_watch_state(['$a', '$b']);
192+
$entries = capture_watch_history(function () {
193+
ddless_step_check('/app/A.php', 10, 'app/A.php', false, '', ['a' => 1, 'b' => 10], fake_backtrace(), '', '', '', '');
194+
ddless_step_check('/app/A.php', 11, 'app/A.php', false, '', ['a' => 2, 'b' => 10], fake_backtrace(), '', '', '', '');
195+
ddless_step_check('/app/A.php', 12, 'app/A.php', false, '', ['a' => 2, 'b' => 20], fake_backtrace(), '', '', '', '');
196+
});
197+
198+
$aEntries = array_values(array_filter($entries, fn($e) => $e['expr'] === '$a'));
199+
$bEntries = array_values(array_filter($entries, fn($e) => $e['expr'] === '$b'));
200+
201+
assert_count(2, $aEntries, '$a: initial + 1 change');
202+
assert_count(2, $bEntries, '$b: initial + 1 change');
203+
assert_eq(1, $aEntries[0]['value']);
204+
assert_eq(2, $aEntries[1]['value']);
205+
assert_eq(10, $bEntries[0]['value']);
206+
assert_eq(20, $bEntries[1]['value']);
207+
});
208+
209+
// ============================================================================
210+
211+
section('Watch history: no watches configured');
212+
213+
test('does not emit anything when __DDLESS_WATCHES__ is empty', function () {
214+
reset_watch_state([]);
215+
$entries = capture_watch_history(function () {
216+
ddless_step_check('/app/A.php', 10, 'app/A.php', false, '', ['x' => 1], fake_backtrace(), '', '', '', '');
217+
});
218+
219+
assert_count(0, $entries);
220+
});
221+
222+
// ============================================================================
223+
224+
exit(print_test_results());

tests/run-all.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
'StepOverLogicTest.php',
2525
'StepInLogicTest.php',
2626
'StepOutLogicTest.php',
27+
'WatchHistoryTest.php',
2728
];
2829

2930
foreach ($testFiles as $file) {

0 commit comments

Comments
 (0)