diff --git a/bin/xstep b/bin/xstep index abb30c1..88f30df 100755 --- a/bin/xstep +++ b/bin/xstep @@ -57,6 +57,8 @@ function showHelp(string $commandName = 'xstep'): void echo "CORE OPTIONS:\n"; echo " --break=SPEC Set breakpoints (file.php:line or file.php:line:condition)\n"; echo " --steps=N Record N steps of variable evolution (JSON output)\n"; + echo " --watch=EXPR Watch expression, only record steps when value changes\n"; + echo " (can be specified multiple times)\n"; echo " --context=TEXT Add contextual description for AI analysis (JSON output)\n"; echo " --include-vendor=PATTERNS Include vendor packages in trace\n"; echo " --help, -h Show this help\n"; @@ -252,6 +254,7 @@ function parseBreakpoints(string $breakSpec, bool $isDockerCommand = false): arr $replMode = $isRepl; // Default to REPL mode if called as xrepl $context = null; $maxSteps = null; + $watches = []; for ($i = 1; $i < count($argv); $i++) { $arg = $argv[$i]; @@ -296,6 +299,11 @@ function parseBreakpoints(string $breakSpec, bool $isDockerCommand = false): arr continue; } + if ($parseOptions && str_starts_with($arg, '--watch=')) { + $watches[] = substr($arg, 8); + continue; + } + if ($parseOptions && str_starts_with($arg, '--include-vendor=')) { // This flag is passed directly to prepend_filter.php via auto_prepend_file continue; @@ -389,11 +397,12 @@ function parseBreakpoints(string $breakSpec, bool $isDockerCommand = false): arr 'traceOnly' => $traceOnly, 'jsonOutput' => $jsonOutput, 'context' => $context, - 'maxSteps' => $maxSteps + 'maxSteps' => $maxSteps, + 'watches' => $watches, ], $jsonOutput); } else { // For legacy format, only pass command if it's not just the target script - $options = ['breakpointFile' => $breakpointFile, 'traceOnly' => $traceOnly, 'jsonOutput' => $jsonOutput, 'context' => $context, 'maxSteps' => $maxSteps]; + $options = ['breakpointFile' => $breakpointFile, 'traceOnly' => $traceOnly, 'jsonOutput' => $jsonOutput, 'context' => $context, 'maxSteps' => $maxSteps, 'watches' => $watches]; if (count($command) > 1 || ($command[0] !== $targetScript)) { $options['command'] = $command; } diff --git a/docs/schemas/xstep.json b/docs/schemas/xstep.json index fdb5975..a1967a3 100644 --- a/docs/schemas/xstep.json +++ b/docs/schemas/xstep.json @@ -41,6 +41,33 @@ "variables": { "type": "object", "description": "Variable values at breakpoint" + }, + "watches": { + "type": "array", + "description": "Watch expression evaluation results (present when --watch is used)", + "items": { + "type": "object", + "properties": { + "expression": { + "type": "string", + "description": "The watched PHP expression" + }, + "value": { + "type": "string", + "description": "Current evaluated value" + }, + "previous": { + "type": ["string", "null"], + "description": "Previous value (null for initial step)" + }, + "reason": { + "type": "string", + "enum": ["initial", "changed", "out_of_scope"], + "description": "Why this watch was recorded" + } + }, + "required": ["expression", "value", "reason"] + } } }, "required": ["location", "variables"] diff --git a/src/DebugServer.php b/src/DebugServer.php index 7a5d120..7008851 100644 --- a/src/DebugServer.php +++ b/src/DebugServer.php @@ -69,6 +69,7 @@ use function libxml_get_errors; use function libxml_use_internal_errors; use function ltrim; +use function md5; use function microtime; use function parse_str; use function preg_match; @@ -136,8 +137,9 @@ final class DebugServer /** @var list, recording_type: string}> */ private array $breaks = []; private bool $isDockerCommand = false; + private bool $stepRecordingOutputDone = false; - /** @param array{command?: list, context?: string, breakpoint?: string, steps?: int, connectionTimeout?: float, executionTimeout?: float, traceOnly?: bool, maxSteps?: int, jsonOutput?: bool, breakpoints?: list, readTimeout?: float} $options */ + /** @param array{command?: list, context?: string, breakpoint?: string, steps?: int, connectionTimeout?: float, executionTimeout?: float, traceOnly?: bool, maxSteps?: int, jsonOutput?: bool, breakpoints?: list, readTimeout?: float, watches?: list} $options */ public function __construct( private readonly string $targetScript, private readonly int $debugPort, @@ -496,11 +498,23 @@ private function performDebugSequence(): void private function performStepTrace(): array { $stepCount = 0; + $recordedCount = 0; $maxSteps = $this->options['maxSteps'] ?? self::MAX_STEPS; $steps = []; $previousVariables = []; // Store previous state for diff comparison - $this->log("🚶 Starting step-by-step execution trace with differential recording (max {$maxSteps} steps)"); + /** @var list $watches */ + $watches = $this->options['watches'] ?? []; + $hasWatches = $watches !== []; + /** @var array $previousWatchValues */ + $previousWatchValues = []; + + // When watches are active, maxSteps limits recorded steps (not executed steps) + // Use a separate safety cap for executed steps to prevent infinite loops + $maxExecutedSteps = $hasWatches ? $maxSteps * 10 : $maxSteps; + + $watchInfo = $hasWatches ? ' with watch filtering (' . count($watches) . ' expressions)' : ''; + $this->log("🚶 Starting step-by-step execution trace with differential recording (max {$maxSteps} steps){$watchInfo}"); while (true) { $stepCount++; @@ -540,6 +554,64 @@ private function performStepTrace(): array // Get current variables $currentVariables = $this->getCurrentVariables(); + // Evaluate watch expressions if active + $watchData = []; + $watchChanged = false; + if ($hasWatches) { + $currentWatchValues = []; + foreach ($watches as $expr) { + $currentWatchValues[$expr] = $this->evaluateWatchExpression($expr); + } + + if ($stepCount === 1) { + // First step: always record with "initial" reason + $watchChanged = true; + foreach ($currentWatchValues as $expr => $value) { + $watchData[] = [ + 'expression' => $expr, + 'value' => $value, + 'previous' => null, + 'reason' => 'initial', + ]; + } + } else { + // Subsequent steps: compare with previous values + foreach ($currentWatchValues as $expr => $value) { + $previous = $previousWatchValues[$expr] ?? ''; + + // Transition from available to unavailable → out_of_scope + if ($value === '' && $previous !== '') { + $watchChanged = true; + $watchData[] = [ + 'expression' => $expr, + 'value' => $value, + 'previous' => $previous, + 'reason' => 'out_of_scope', + ]; + continue; + } + + // Skip if both unavailable + if ($value === '') { + continue; + } + + // Value changed + if ($value !== $previous) { + $watchChanged = true; + $watchData[] = [ + 'expression' => $expr, + 'value' => $value, + 'previous' => $previous !== '' ? $previous : null, + 'reason' => 'changed', + ]; + } + } + } + + $previousWatchValues = $currentWatchValues; + } + // Implement differential recording (like video compression) if ($stepCount === 1) { // First frame: record all variables (full state) @@ -568,19 +640,56 @@ private function performStepTrace(): array $previousVariables = $currentVariables; } - // Record every step (not just variable changes) + // When watches are active, only record steps where watched values changed + if ($hasWatches && ! $watchChanged) { + $this->log("Step {$stepCount}: {$location['file']}:{$location['line']} (watch unchanged, skipped)"); + + // Still step into next instruction even when skipping recording + $this->log('👣 Step into...'); + $stepResponse = $this->stepInto(); + + if ($this->isExecutionComplete($stepResponse)) { + $this->log("✅ Execution completed after {$stepCount} steps"); + if ($this->jsonMode || ($this->options['jsonOutput'] ?? false)) { + array_push($this->breaks, ...$steps); + $this->outputStepRecordingResults(); + } + + break; + } + + // Safety cap for executed steps (prevents infinite loops) + if ($stepCount >= $maxExecutedSteps) { + $this->log("⚠️ Maximum executed steps ({$maxExecutedSteps}) reached, stopping execution"); + if ($this->jsonMode || ($this->options['jsonOutput'] ?? false)) { + array_push($this->breaks, ...$steps); + $this->outputStepRecordingResults(); + } + + break; + } + + continue; + } + + // Record the step $step = [ 'step' => $stepCount, 'location' => $location, 'variables' => $variablesToRecord, - 'recording_type' => $recordingType, // Indicate full vs diff recording + 'recording_type' => $recordingType, ]; + if ($hasWatches && $watchData !== []) { + $step['watches'] = $watchData; + } + $steps[] = $step; + $recordedCount++; - // Check if we've reached the step limit AFTER recording the step - if ($stepCount >= $maxSteps) { - $this->log("⚠️ Maximum steps ({$maxSteps}) reached, stopping execution"); + // Check if we've reached the recorded step limit AFTER recording the step + if ($recordedCount >= $maxSteps) { + $this->log("⚠️ Maximum recorded steps ({$maxSteps}) reached, stopping execution"); // Output JSON results immediately when step limit is reached if ($this->jsonMode || ($this->options['jsonOutput'] ?? false)) { @@ -594,7 +703,8 @@ private function performStepTrace(): array if ($this->jsonMode) { $changeCount = count($variablesToRecord); $type = $recordingType === 'full' ? 'full state' : 'changes only'; - $this->log("Step {$stepCount}: {$location['file']}:{$location['line']} ({$changeCount} variables, {$type})"); + $watchNote = $hasWatches ? ', watch changed' : ''; + $this->log("Step {$stepCount}: {$location['file']}:{$location['line']} ({$changeCount} variables, {$type}{$watchNote})"); } else { $this->displayStackInfo($stackResponse); $title = $recordingType === 'full' @@ -924,6 +1034,135 @@ private function evaluateExpression(string $expression): string return $this->sendCommand('eval', ['--' => $encoded]); } + /** + * Evaluate a watch expression and return its string value + * + * @return string The evaluated value as a string, or '' on error + */ + private function evaluateWatchExpression(string $expression): string + { + try { + $response = $this->evaluateExpression($expression); + if ($response === '' || str_contains($response, ''; + } + + $xml = $this->parseXmlResponse($response); + if (! $xml) { + return ''; + } + + // Handle property_get response + $prop = $xml->property ?? ($xml->children()[0] ?? null); + if ($prop === null) { + return ''; + } + + $encoding = (string) ($prop['encoding'] ?? ''); + $value = (string) $prop; + + if ($encoding === 'base64') { + $value = base64_decode($value); + } + + $type = (string) ($prop['type'] ?? 'string'); + + // Format the value with type context + if ($type === 'string') { + return "'" . $value . "'"; + } + + if ($type === 'null') { + return 'null'; + } + + if ($type === 'bool') { + return $value === '1' || strtolower($value) === 'true' ? 'true' : 'false'; + } + + if ($type === 'array' || $type === 'object') { + // Use content hash to detect internal mutations + $contentHash = $this->getWatchContentHash($expression); + if ($contentHash !== null) { + if ($type === 'array') { + $numChildren = (string) ($prop['numchildren'] ?? '0'); + + return 'array(' . $numChildren . ')#' . $contentHash; + } + + $className = (string) ($prop['classname'] ?? 'object'); + + return 'object: ' . $className . '#' . $contentHash; + } + + // Fallback without hash + if ($type === 'array') { + $numChildren = (string) ($prop['numchildren'] ?? '0'); + + return 'array(' . $numChildren . ')'; + } + + $className = (string) ($prop['classname'] ?? 'object'); + + return 'object: ' . $className; + } + + // int, float, or other types + return $value !== '' ? $value : ''; + } catch (Throwable) { + return ''; + } + } + + /** + * Get a content-based hash for array/object watch expressions + * + * Uses json_encode via eval to detect internal mutations. + * + * @return string|null Short hash string, or null if unavailable + */ + private function getWatchContentHash(string $expression): string|null + { + try { + // Use property_get to retrieve child elements (depth 0 = current frame) + $response = $this->sendCommand('property_get', ['n' => $expression, 'd' => '0']); + if ($response === '' || str_contains($response, 'parseXmlResponse($response); + if (! $xml) { + return null; + } + + $prop = $xml->property ?? ($xml->children()[0] ?? null); + if ($prop === null) { + return null; + } + + // Build a content signature from child properties + $signature = ''; + foreach ($prop->property as $child) { + $childName = (string) ($child['name'] ?? ''); + $childValue = (string) $child; + if ((string) ($child['encoding'] ?? '') === 'base64') { + $childValue = base64_decode($childValue); + } + + $signature .= $childName . '=' . $childValue . ';'; + } + + if ($signature === '') { + return null; + } + + // Short hash (8 chars) for compact output + return substr(md5($signature), 0, 8); + } catch (Throwable) { + return null; + } + } + /** * Check if response indicates breakpoint hit */ @@ -2139,6 +2378,12 @@ private function cleanup(): void */ private function outputStepRecordingResults(): void { + if ($this->stepRecordingOutputDone) { + return; + } + + $this->stepRecordingOutputDone = true; + $result = [ '$schema' => 'https://koriym.github.io/xdebug-mcp/schemas/xstep.json', 'breaks' => $this->breaks, diff --git a/tests/Unit/DebugServerTest.php b/tests/Unit/DebugServerTest.php index dc9ac66..cf3ff55 100644 --- a/tests/Unit/DebugServerTest.php +++ b/tests/Unit/DebugServerTest.php @@ -300,6 +300,61 @@ public function testCliJsonOutputWithBreakpointAndSteps(): void $this->assertEquals(30, $serverOptions['timeout']); } + public function testConstructorWithWatches(): void + { + $options = [ + 'watches' => ['$i', '$user->getStatus()'], + 'maxSteps' => 50, + ]; + + $server = new DebugServer($this->testScript, 9004, null, $options, true); + + $reflection = new ReflectionClass($server); + $optionsProperty = $reflection->getProperty('options'); + $serverOptions = $optionsProperty->getValue($server); + + $this->assertArrayHasKey('watches', $serverOptions); + $this->assertCount(2, $serverOptions['watches']); + $this->assertEquals('$i', $serverOptions['watches'][0]); + $this->assertEquals('$user->getStatus()', $serverOptions['watches'][1]); + } + + public function testConstructorWithEmptyWatches(): void + { + $options = [ + 'watches' => [], + ]; + + $server = new DebugServer($this->testScript, 9004, null, $options, true); + + $reflection = new ReflectionClass($server); + $optionsProperty = $reflection->getProperty('options'); + $serverOptions = $optionsProperty->getValue($server); + + $this->assertArrayHasKey('watches', $serverOptions); + $this->assertEmpty($serverOptions['watches']); + } + + public function testEvaluateWatchExpressionMethodExists(): void + { + $server = new DebugServer($this->testScript, 9004); + + $reflection = new ReflectionClass($server); + $this->assertTrue($reflection->hasMethod('evaluateWatchExpression')); + + $method = $reflection->getMethod('evaluateWatchExpression'); + $this->assertTrue($method->isPrivate()); + + // Verify method signature: takes string, returns string + $params = $method->getParameters(); + $this->assertCount(1, $params); + $this->assertEquals('expression', $params[0]->getName()); + + $returnType = $method->getReturnType(); + $this->assertNotNull($returnType); + $this->assertEquals('string', $returnType->getName()); + } + /** * Test that DebugServer creates proper Xdebug arguments for different configurations */