diff --git a/README.md b/README.md index 017f600..11a5d5b 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,14 @@ Works as-is. Just swap the extension and your existing debug configurations work Works as-is. No changes needed. +### Agents + +[Agents CLI](cli/README.md) + +``` +dbg -breakpoint src/Controller/HomeController.php:25 +``` + ## Xdebug Compatibility PHP Debugger is a drop-in replacement for Xdebug\'s debug mode: diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..efa6632 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1 @@ +bin/* \ No newline at end of file diff --git a/cli/Makefile b/cli/Makefile new file mode 100644 index 0000000..3453582 --- /dev/null +++ b/cli/Makefile @@ -0,0 +1,31 @@ +.PHONY: build install clean test run + +BINARY_NAME=dbg +INSTALL_PATH?=/usr/local/bin + +build: + go build -o bin/$(BINARY_NAME) . + +install: build + cp bin/$(BINARY_NAME) $(INSTALL_PATH)/$(BINARY_NAME) + +clean: + rm -rf bin/ + +test: + go test ./... + +run: build + ./bin/$(BINARY_NAME) + +# Development +dev: + go run . + +# Cross-compile for different platforms +build-all: + GOOS=linux GOARCH=amd64 go build -o bin/$(BINARY_NAME)-linux-amd64 . + GOOS=linux GOARCH=arm64 go build -o bin/$(BINARY_NAME)-linux-arm64 . + GOOS=darwin GOARCH=amd64 go build -o bin/$(BINARY_NAME)-darwin-amd64 . + GOOS=darwin GOARCH=arm64 go build -o bin/$(BINARY_NAME)-darwin-arm64 . + GOOS=windows GOARCH=amd64 go build -o bin/$(BINARY_NAME)-windows-amd64.exe . diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..3095401 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,111 @@ +# PHP Debugger CLI + +A command-line debugger for PHP using the DBGp protocol. Works with the `php_debugger` extension. + +## Build + +```bash +cd cli && make build +``` + +Binary: `bin/dbg` + +## Usage + +``` +PHP Debugger CLI - DBGp Protocol Debug Tool + +DESCRIPTION + A command-line debugger for PHP applications using the DBGp protocol. + Listens for incoming connections from PHP's Xdebug or php-debugger extension + to set breakpoints, inspect variables, and step through code execution. + +USAGE + dbg [options] + +OPTIONS + -port TCP port for DBGp connection (default: 9003) + -timeout Time to wait for PHP connection before exiting (default: 30s) + -breakpoint Set breakpoint, can be repeated (see format below) + -raw Output raw XML responses below parsed variables + +BREAKPOINT FORMAT + file.php:42 Single breakpoint at line 42 + file.php:10,20,30 Multiple lines in same file + path/to/file.php:100 With relative or absolute path + +EXAMPLES + dbg -breakpoint app.php:42 + dbg -breakpoint Controller.php:10,20,30 -breakpoint Model.php:50 + dbg -port 9003 + +RUNNING PHP SCRIPTS + dbg -breakpoint app.php:42 app.php + dbg -breakpoint Controller.php:10 app.php [script args...] +``` + +## Example with Symfony + +Terminal 1 - Start the debugger: +```bash +dbg -breakpoint src/Controller/HomeController.php:25 +``` + +Terminal 2 - Start Symfony with debugging enabled: +```bash +PHP_DEBUGGER_TRIGGER=1 symfony server:start +``` + +When your app hits the breakpoint, the debugger shows variables and stack trace. + +``` +Listening on 127.0.0.1:9003 +Waiting for PHP to connect... + +✓ Connected: /home/daniel/projects/espend-de/public/index.php +✓ Breakpoint: HomeController.php:36 + +→ Running... + +━━━ HomeController.php:36 ━━━ + +Stack: +#0 App\Controller\HomeController->index() at HomeController.php:36 +#1 Symfony\Component\HttpKernel\HttpKernel->handleRaw() at HttpKernel.php:183 +#2 Symfony\Component\HttpKernel\HttpKernel->handle() at HttpKernel.php:76 +#3 Symfony\Component\HttpKernel\Kernel->handle() at Kernel.php:193 +#4 Symfony\Component\Runtime\Runner\Symfony\HttpKernelRunner->run() at HttpKernelRunner.php:35 +#5 require_once() at autoload_runtime.php:32 +#6 {main}() at index.php:5 + +Variables: +$featureCollector object = App\Service\SymfonyFeatureCollector + $featureCollector->projectDir string = "/home/daniel/projects/espend-de" + $featureCollector->cache object = Symfony\Component\Cache\Adapter\TraceableAdapter +$github uninitialized = null +$latestFeatures array = array[14] + $latestFeatures[0] array = array[3] + $latestFeatures[1] array = array[3] + $latestFeatures[2] array = array[3] + $latestFeatures[3] array = array[3] + $latestFeatures[4] array = array[3] + $latestFeatures[5] array = array[3] + $latestFeatures[6] array = array[3] + $latestFeatures[7] array = array[3] + $latestFeatures[8] array = array[3] + $latestFeatures[9] array = array[3] + $latestFeatures[10] array = array[3] + $latestFeatures[11] array = array[3] + $latestFeatures[12] array = array[3] + $latestFeatures[13] array = array[3] +$p null = null +$plugin uninitialized = null +$pluginRepository object = App\Repository\PluginRepository{} +$pluginUpdateInfo array = array[6] + $pluginUpdateInfo["symfony"] array = array[4] + $pluginUpdateInfo["shopware"] array = array[4] + $pluginUpdateInfo["php-annotations"] array = array[4] + $pluginUpdateInfo["laravel"] array = array[4] + $pluginUpdateInfo["phpunit"] array = array[4] + $pluginUpdateInfo["vuejs-toolbox"] array = array[4] +``` \ No newline at end of file diff --git a/cli/dbgp/client.go b/cli/dbgp/client.go new file mode 100644 index 0000000..b65d247 --- /dev/null +++ b/cli/dbgp/client.go @@ -0,0 +1,402 @@ +package dbgp + +import ( + "bufio" + "encoding/base64" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "time" +) + +// Client manages the DBGp connection +type Client struct { + listener net.Listener + conn net.Conn + reader *bufio.Reader + writer io.Writer + + mu sync.Mutex + transID int + responses map[int]chan *Response + + init *InitPacket + + onBreakpoint func(file string, line int, stack []StackFrame, vars []Variable) +} + +// NewClient creates a new DBGp client (server that accepts PHP connections) +func NewClient(port int) (*Client, error) { + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return nil, fmt.Errorf("listen on port %d: %w", port, err) + } + + return &Client{ + listener: listener, + responses: make(map[int]chan *Response), + }, nil +} + +// Addr returns the address the client is listening on +func (c *Client) Addr() string { + return c.listener.Addr().String() +} + +// Port returns the port number +func (c *Client) Port() int { + addr := c.Addr() + _, port, _ := net.SplitHostPort(addr) + p, _ := strconv.Atoi(port) + return p +} + +// WaitForConnection waits for PHP to connect +func (c *Client) WaitForConnection(timeout time.Duration) error { + // Set accept deadline if timeout specified + if timeout > 0 { + tcpListener := c.listener.(*net.TCPListener) + tcpListener.SetDeadline(time.Now().Add(timeout)) + } + + conn, err := c.listener.Accept() + if err != nil { + return fmt.Errorf("accept connection: %w", err) + } + + c.conn = conn + c.reader = bufio.NewReader(conn) + c.writer = conn + + // Start response reader + go c.readLoop() + + // Read init packet + initData, err := c.readPacket() + if err != nil { + return fmt.Errorf("read init packet: %w", err) + } + + c.init, err = ParseInit(initData) + if err != nil { + return fmt.Errorf("parse init packet: %w", err) + } + + return nil +} + +// Init returns the initialization packet from PHP +func (c *Client) Init() *InitPacket { + return c.init +} + +// OnBreakpoint sets the callback for breakpoint hits +func (c *Client) OnBreakpoint(fn func(file string, line int, stack []StackFrame, vars []Variable)) { + c.onBreakpoint = fn +} + +// readPacket reads a single DBGp packet (length + NULL + data) +func (c *Client) readPacket() ([]byte, error) { + // Read length until NULL + line, err := c.reader.ReadString('\x00') + if err != nil { + return nil, err + } + + // Parse length + line = strings.TrimSuffix(line, "\x00") + length, err := strconv.Atoi(line) + if err != nil { + return nil, fmt.Errorf("parse packet length: %w", err) + } + + // Read exact number of bytes + data := make([]byte, length) + _, err = io.ReadFull(c.reader, data) + if err != nil { + return nil, err + } + + // Read trailing NULL + b, err := c.reader.ReadByte() + if err != nil { + return nil, err + } + if b != '\x00' { + return nil, fmt.Errorf("expected NULL terminator, got %x", b) + } + + return data, nil +} + +// readLoop continuously reads responses and dispatches them +func (c *Client) readLoop() { + for { + data, err := c.readPacket() + if err != nil { + // Connection closed + return + } + + resp, err := ParseResponse(data) + if err != nil { + continue + } + + // Check if this is a breakpoint hit (async response) + if resp.Status == StatusBreak && c.onBreakpoint != nil { + go c.handleBreakpoint(resp) + } + + // Dispatch to waiting sender + c.mu.Lock() + ch, ok := c.responses[resp.Transaction] + if ok { + ch <- resp + delete(c.responses, resp.Transaction) + } + c.mu.Unlock() + } +} + +// handleBreakpoint handles async breakpoint notifications +func (c *Client) handleBreakpoint(resp *Response) { + file, line := resp.ParseMessage() + + // Get stack + stack, _ := c.GetStack() + + // Get local variables + vars, _ := c.GetContext(0, 0) + + if c.onBreakpoint != nil { + c.onBreakpoint(file, line, stack, vars) + } +} + +// nextTransID generates a new transaction ID +func (c *Client) nextTransID() int { + c.mu.Lock() + defer c.mu.Unlock() + c.transID++ + return c.transID +} + +// sendCommand sends a command and waits for response +func (c *Client) sendCommand(cmd string) (*Response, error) { + if c.conn == nil { + return nil, fmt.Errorf("not connected") + } + + transID := c.nextTransID() + + // Ensure transaction ID is in command + if !strings.Contains(cmd, "-i") { + cmd = fmt.Sprintf("%s -i %d", cmd, transID) + } else { + // Extract transaction ID from command + parts := strings.Split(cmd, "-i ") + if len(parts) > 1 { + idStr := strings.Fields(parts[1])[0] + id, _ := strconv.Atoi(idStr) + transID = id + } + } + + // Create response channel + ch := make(chan *Response, 1) + c.mu.Lock() + c.responses[transID] = ch + c.mu.Unlock() + + // Send command + packet := fmt.Sprintf("%d\x00%s\x00", len(cmd), cmd) + _, err := c.writer.Write([]byte(packet)) + if err != nil { + c.mu.Lock() + delete(c.responses, transID) + c.mu.Unlock() + return nil, fmt.Errorf("send command: %w", err) + } + + // Wait for response with timeout + select { + case resp := <-ch: + if resp.Error != nil { + return resp, fmt.Errorf("error %d: %s", resp.Error.Code, resp.Error.Message) + } + return resp, nil + case <-time.After(30 * time.Second): + c.mu.Lock() + delete(c.responses, transID) + c.mu.Unlock() + return nil, fmt.Errorf("timeout waiting for response") + } +} + +// SetBreakpoint sets a line breakpoint +func (c *Client) SetBreakpoint(file string, line int) (int, error) { + uri := MakeFileURI(file) + cmd := fmt.Sprintf("breakpoint_set -t line -f %s -n %d", uri, line) + resp, err := c.sendCommand(cmd) + if err != nil { + return 0, err + } + return resp.BreakpointID, nil +} + +// SetConditionalBreakpoint sets a conditional breakpoint +func (c *Client) SetConditionalBreakpoint(file string, line int, condition string) (int, error) { + uri := MakeFileURI(file) + encoded := base64.StdEncoding.EncodeToString([]byte(condition)) + cmd := fmt.Sprintf("breakpoint_set -t conditional -f %s -n %d -- %s", uri, line, encoded) + resp, err := c.sendCommand(cmd) + if err != nil { + return 0, err + } + return resp.BreakpointID, nil +} + +// RemoveBreakpoint removes a breakpoint +func (c *Client) RemoveBreakpoint(id int) error { + cmd := fmt.Sprintf("breakpoint_remove -d %d", id) + _, err := c.sendCommand(cmd) + return err +} + +// ListBreakpoints lists all breakpoints +func (c *Client) ListBreakpoints() ([]BreakpointInfo, error) { + resp, err := c.sendCommand("breakpoint_list") + if err != nil { + return nil, err + } + + // Parse breakpoints from response + var breakpoints []BreakpointInfo + // TODO: Parse from resp.Raw + _ = resp + return breakpoints, nil +} + +// Run starts or continues execution +func (c *Client) Run() error { + _, err := c.sendCommand("run") + return err +} + +// StepInto steps into the next statement +func (c *Client) StepInto() error { + _, err := c.sendCommand("step_into") + return err +} + +// StepOver steps over the next statement +func (c *Client) StepOver() error { + _, err := c.sendCommand("step_over") + return err +} + +// StepOut steps out of the current function +func (c *Client) StepOut() error { + _, err := c.sendCommand("step_out") + return err +} + +// Stop stops execution +func (c *Client) Stop() error { + _, err := c.sendCommand("stop") + return err +} + +// Detach detaches the debugger (script continues) +func (c *Client) Detach() error { + _, err := c.sendCommand("detach") + return err +} + +// GetStack returns the call stack +func (c *Client) GetStack() ([]StackFrame, error) { + resp, err := c.sendCommand("stack_get") + if err != nil { + return nil, err + } + return resp.Stack, nil +} + +// GetContext returns variables at the given depth and context +func (c *Client) GetContext(depth int, context int) ([]Variable, error) { + cmd := fmt.Sprintf("context_get -d %d -c %d", depth, context) + resp, err := c.sendCommand(cmd) + if err != nil { + return nil, err + } + return ParseVariables(resp.Raw), nil +} + +// Eval evaluates an expression +func (c *Client) Eval(code string) (string, error) { + encoded := base64.StdEncoding.EncodeToString([]byte(code)) + cmd := fmt.Sprintf("eval -- %s", encoded) + resp, err := c.sendCommand(cmd) + if err != nil { + return "", err + } + return resp.Raw, nil +} + +// GetSource returns source code +func (c *Client) GetSource(file string, begin, end int) (string, error) { + uri := MakeFileURI(file) + cmd := fmt.Sprintf("source -f %s", uri) + if begin > 0 { + cmd += fmt.Sprintf(" -b %d", begin) + } + if end > 0 { + cmd += fmt.Sprintf(" -e %d", end) + } + resp, err := c.sendCommand(cmd) + if err != nil { + return "", err + } + return resp.Raw, nil +} + +// Status returns current debugger status +func (c *Client) Status() (string, error) { + resp, err := c.sendCommand("status") + if err != nil { + return "", err + } + return resp.Status, nil +} + +// FeatureGet gets a feature value +func (c *Client) FeatureGet(name string) (string, error) { + cmd := fmt.Sprintf("feature_get -n %s", name) + resp, err := c.sendCommand(cmd) + if err != nil { + return "", err + } + return resp.Raw, nil +} + +// FeatureSet sets a feature value +func (c *Client) FeatureSet(name, value string) error { + cmd := fmt.Sprintf("feature_set -n %s -v %s", name, value) + _, err := c.sendCommand(cmd) + return err +} + +// Close closes the connection +func (c *Client) Close() error { + if c.conn != nil { + c.conn.Close() + } + if c.listener != nil { + c.listener.Close() + } + return nil +} diff --git a/cli/dbgp/parser.go b/cli/dbgp/parser.go new file mode 100644 index 0000000..67e984d --- /dev/null +++ b/cli/dbgp/parser.go @@ -0,0 +1,179 @@ +package dbgp + +import ( + "bytes" + "encoding/base64" + "encoding/xml" + "fmt" + "strings" +) + +// Variable represents a parsed variable +type Variable struct { + Name string + Type string + Value string + Level int // nesting level for indentation +} + +// ContextResponse wraps the XML response for context_get +type ContextResponse struct { + XMLName xml.Name `xml:"response"` + Properties []Property `xml:"property"` +} + +// ParseVariables extracts top-level variables and their immediate children from a DBGp context_get response +func ParseVariables(xmlData string) []Variable { + var resp ContextResponse + decoder := xml.NewDecoder(bytes.NewReader([]byte(xmlData))) + decoder.CharsetReader = charsetReader + + if err := decoder.Decode(&resp); err != nil { + return nil + } + + var vars []Variable + for _, p := range resp.Properties { + // Only include top-level variables (no brackets or arrows in name) + if containsAny(p.FullName, "[", "->") { + continue + } + vars = append(vars, Variable{ + Name: p.FullName, + Type: p.Type, + Value: formatPropertyValue(p), + Level: 0, + }) + // Add immediate child properties for objects/arrays + for _, child := range p.ChildProperties { + vars = append(vars, Variable{ + Name: child.FullName, + Type: child.Type, + Value: formatPropertyValue(child), + Level: 1, + }) + } + } + return vars +} + +// ParseAllVariables extracts all variables including nested ones +func ParseAllVariables(xmlData string) []Variable { + var resp ContextResponse + decoder := xml.NewDecoder(bytes.NewReader([]byte(xmlData))) + decoder.CharsetReader = charsetReader + + if err := decoder.Decode(&resp); err != nil { + return nil + } + + return flattenProperties(resp.Properties) +} + +// flattenProperties recursively flattens nested properties +func flattenProperties(props []Property) []Variable { + var vars []Variable + for _, p := range props { + vars = append(vars, Variable{ + Name: p.FullName, + Type: p.Type, + Value: formatPropertyValue(p), + }) + if len(p.ChildProperties) > 0 { + vars = append(vars, flattenProperties(p.ChildProperties)...) + } + } + return vars +} + +// formatPropertyValue formats a property value for display +func formatPropertyValue(p Property) string { + // Handle types with children (check Children attr, ChildProperties, or raw XML in Value) + hasChildren := p.Children > 0 || len(p.ChildProperties) > 0 || + (len(p.Value) > 0 && p.Value[0] == '<') + + if hasChildren { + count := p.NumChildren + if count == 0 { + count = len(p.ChildProperties) + } + if count == 0 { + count = p.Children // fallback + } + if count == 0 { + count = 1 // at least one if we detected children + } + if p.Type == "object" && p.ClassName != "" { + return p.ClassName + } + return fmt.Sprintf("%s[%d]", p.Type, count) + } + + // Decode base64 if needed + content := p.Value + if p.Encoding == "base64" && content != "" { + if decoded, err := base64.StdEncoding.DecodeString(content); err == nil { + content = string(decoded) + } + } + + return formatSimpleValue(p.Type, content, p.ClassName) +} + +// formatSimpleValue formats a value for display +func formatSimpleValue(typ, content, classname string) string { + switch typ { + case "null", "uninitialized": + return "null" + case "bool": + if content == "1" || content == "true" { + return "true" + } + return "false" + case "string": + if content == "" { + return `""` + } + if len(content) > 60 { + return `"` + content[:55] + `..."` + } + return `"` + content + `"` + case "int", "float": + return content + case "array": + if classname != "" { + return classname + "[]" + } + return "array[]" + case "object": + if classname != "" { + return classname + "{}" + } + return "object{}" + default: + if content == "" { + return "<" + typ + ">" + } + if len(content) > 60 { + return content[:57] + "..." + } + return content + } +} + +// FormatVariable formats a variable for display +func FormatVariable(v Variable) string { + indent := strings.Repeat(" ", v.Level) + width := 30 - (v.Level * 2) + return fmt.Sprintf("%s%-*s %-8s = %s", indent, width, v.Name, v.Type, v.Value) +} + +// containsAny checks if s contains any of the substrings +func containsAny(s string, subs ...string) bool { + for _, sub := range subs { + if bytes.Contains([]byte(s), []byte(sub)) { + return true + } + } + return false +} diff --git a/cli/dbgp/parser_test.go b/cli/dbgp/parser_test.go new file mode 100644 index 0000000..bfe8ae1 --- /dev/null +++ b/cli/dbgp/parser_test.go @@ -0,0 +1,125 @@ +package dbgp + +import ( + "os" + "testing" +) + +func TestFormatSimpleValue(t *testing.T) { + tests := []struct { + typ string + content string + classname string + want string + }{ + {"string", "hello", "", `"hello"`}, + {"string", "", "", `""`}, + {"int", "42", "", "42"}, + {"bool", "1", "", "true"}, + {"bool", "0", "", "false"}, + {"null", "", "", "null"}, + {"array", "", "App\\Foo", "App\\Foo[]"}, + {"array", "", "", "array[]"}, + {"object", "", "App\\Service", "App\\Service{}"}, + } + + for _, tt := range tests { + got := formatSimpleValue(tt.typ, tt.content, tt.classname) + if got != tt.want { + t.Errorf("formatSimpleValue(%q, %q, %q) = %q, want %q", tt.typ, tt.content, tt.classname, got, tt.want) + } + } +} + +func TestFormatPropertyValue(t *testing.T) { + tests := []struct { + name string + prop Property + want string + }{ + { + name: "base64 string", + prop: Property{Type: "string", Encoding: "base64", Value: "SGVsbG8sIFdvcmxkIQ=="}, + want: `"Hello, World!"`, + }, + { + name: "int", + prop: Property{Type: "int", Value: "42"}, + want: "42", + }, + { + name: "bool true", + prop: Property{Type: "bool", Value: "1"}, + want: "true", + }, + { + name: "array with children", + prop: Property{Type: "array", Children: 5}, + want: "array[5]", + }, + { + name: "object with classname", + prop: Property{Type: "object", ClassName: "App\\Service", Children: 2}, + want: "App\\Service", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatPropertyValue(tt.prop) + if got != tt.want { + t.Errorf("formatPropertyValue() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestParseVariablesFromRealXML(t *testing.T) { + data, err := os.ReadFile("testdata/context_get_complex.xml") + if err != nil { + t.Fatalf("read test file: %v", err) + } + + vars := ParseVariables(string(data)) + + // Find specific variables + findVar := func(name string) *Variable { + for i := range vars { + if vars[i].Name == name { + return &vars[i] + } + } + return nil + } + + tests := []struct { + name string + wantType string + wantValue string + }{ + {"$count", "string", `"Hello, World!"`}, + {"$name", "string", `"World"`}, + {"$sum", "int", "15"}, + {"$numbers", "array", "array[5]"}, + {"$user", "array", "array[3]"}, + {"$obj", "object", `App\Service\MyService`}, + {"$emptyArray", "array", "array[]"}, + {"$nullVal", "null", "null"}, + {"$boolVal", "bool", "true"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := findVar(tt.name) + if v == nil { + t.Fatalf("variable %s not found", tt.name) + } + if v.Type != tt.wantType { + t.Errorf("type = %q, want %q", v.Type, tt.wantType) + } + if v.Value != tt.wantValue { + t.Errorf("value = %q, want %q", v.Value, tt.wantValue) + } + }) + } +} diff --git a/cli/dbgp/protocol.go b/cli/dbgp/protocol.go new file mode 100644 index 0000000..05e078a --- /dev/null +++ b/cli/dbgp/protocol.go @@ -0,0 +1,247 @@ +// Package dbgp implements the DBGp protocol for PHP debugging +package dbgp + +import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "strconv" + "strings" +) + +// charsetReader handles non-UTF-8 XML encodings by treating them as UTF-8 +func charsetReader(charset string, input io.Reader) (io.Reader, error) { + // Treat all encodings as UTF-8 (DBGp typically uses ASCII-safe content anyway) + if strings.EqualFold(charset, "iso-8859-1") || + strings.EqualFold(charset, "windows-1252") || + strings.EqualFold(charset, "utf-8") || + strings.EqualFold(charset, "us-ascii") { + return input, nil + } + return nil, fmt.Errorf("unsupported charset: %s", charset) +} + +// Status values +const ( + StatusStarting = "starting" + StatusStopping = "stopping" + StatusStopped = "stopped" + StatusRunning = "running" + StatusBreak = "break" + StatusDetached = "detached" +) + +// Breakpoint types +const ( + BreakpointLine = "line" + BreakpointConditional = "conditional" + BreakpointCall = "call" + BreakpointReturn = "return" + BreakpointException = "exception" + BreakpointWatch = "watch" +) + +// InitPacket is sent by PHP when connection is established +type InitPacket struct { + XMLName xml.Name `xml:"init"` + AppID string `xml:"appid,attr"` + IDEKey string `xml:"idekey,attr"` + Session string `xml:"session,attr"` + Thread string `xml:"thread,attr"` + Parent string `xml:"parent,attr"` + Language string `xml:"language,attr"` + Protocol string `xml:"protocol_version,attr"` + FileURI string `xml:"fileuri"` + EngineVersion string `xml:"engine>version"` +} + +// Response is the generic DBGp response +type Response struct { + XMLName xml.Name `xml:"response"` + Command string `xml:"command,attr"` + Transaction int `xml:"transaction_id,attr"` + Status string `xml:"status,attr,omitempty"` + Reason string `xml:"reason,attr,omitempty"` + Success string `xml:"success,attr,omitempty"` + BreakpointID int `xml:"id,attr,omitempty"` + + // For breakpoint_set + Breakpoint *BreakpointInfo `xml:"breakpoint,omitempty"` + + // For context_get + Context int `xml:"context,attr,omitempty"` + Properties []Property `xml:"property,omitempty"` + + // For stack_get + Stack []StackFrame `xml:"stack,omitempty"` + + // For errors + Error *Error `xml:"error,omitempty"` + + // Message for breakpoint hit + Message *Message `xml:"xdebug\\:message,omitempty"` + + // Raw for debugging + Raw string `xml:",innerxml"` +} + +// Error represents a DBGp error +type Error struct { + Code int `xml:"code,attr"` + Message string `xml:"message"` +} + +// Message contains breakpoint hit information +type Message struct { + Filename string `xml:"filename,attr"` + Lineno int `xml:"lineno,attr"` +} + +// BreakpointInfo contains breakpoint details +type BreakpointInfo struct { + ID int `xml:"id,attr"` + Type string `xml:"type,attr"` + Filename string `xml:"filename,attr"` + Lineno int `xml:"lineno,attr"` + State string `xml:"state,attr"` + Exception string `xml:"exception,attr,omitempty"` + Expression string `xml:"expression,attr,omitempty"` + HitCount int `xml:"hit_count,attr,omitempty"` + HitValue int `xml:"hit_value,attr,omitempty"` + Temporary int `xml:"temporary,attr,omitempty"` +} + +// Property represents a variable +type Property struct { + Name string `xml:"name,attr"` + FullName string `xml:"fullname,attr"` + Type string `xml:"type,attr"` + ClassName string `xml:"classname,attr,omitempty"` + Facet string `xml:"facet,attr,omitempty"` + Size int `xml:"size,attr,omitempty"` + Children int `xml:"children,attr,omitempty"` + NumChildren int `xml:"numchildren,attr,omitempty"` + Encoding string `xml:"encoding,attr,omitempty"` + Value string `xml:",chardata"` + ChildProperties []Property `xml:"property,omitempty"` +} + +// StackFrame represents a call stack entry +type StackFrame struct { + Level int `xml:"level,attr"` + Type string `xml:"type,attr"` + Filename string `xml:"filename,attr"` + Lineno int `xml:"lineno,attr"` + Where string `xml:"where,attr"` + Cmmd string `xml:"cmmd,attr,omitempty"` +} + +// ParseInit parses the init packet from PHP +func ParseInit(data []byte) (*InitPacket, error) { + var init InitPacket + decoder := xml.NewDecoder(bytes.NewReader(data)) + decoder.CharsetReader = charsetReader + if err := decoder.Decode(&init); err != nil { + return nil, fmt.Errorf("parse init: %w", err) + } + return &init, nil +} + +// ParseResponse parses a DBGp response +func ParseResponse(data []byte) (*Response, error) { + var resp Response + decoder := xml.NewDecoder(bytes.NewReader(data)) + decoder.CharsetReader = charsetReader + if err := decoder.Decode(&resp); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + return &resp, nil +} + +// ParseMessage extracts breakpoint hit info from response +func (r *Response) ParseMessage() (file string, line int) { + if r.Message != nil { + file = strings.TrimPrefix(r.Message.Filename, "file://") + line = r.Message.Lineno + } + return +} + +func formatValue(p Property) string { + if p.Encoding == "base64" { + return "" + } + + if p.Type == "array" || p.Type == "object" { + if p.Children > 0 { + return fmt.Sprintf("%s(%d)", p.Type, p.Children) + } + return p.Type + "(0)" + } + + if p.Value == "" { + switch p.Type { + case "null": + return "null" + case "bool": + return "false" + case "string": + return `""` + default: + return p.Type + } + } + + // Truncate long values + if len(p.Value) > 100 { + return p.Value[:97] + "..." + } + + return p.Value +} + +// FormatStack formats the call stack for display +func FormatStack(frames []StackFrame) []string { + lines := make([]string, len(frames)) + for i, f := range frames { + file := strings.TrimPrefix(f.Filename, "file://") + lines[i] = fmt.Sprintf("#%d %s() at %s:%d", f.Level, f.Where, file, f.Lineno) + } + return lines +} + +// FormatFileURI converts file:// URI to path +func FormatFileURI(uri string) string { + return strings.TrimPrefix(uri, "file://") +} + +// MakeFileURI converts path to file:// URI +func MakeFileURI(path string) string { + if strings.HasPrefix(path, "file://") { + return path + } + return "file://" + path +} + +// ParseBreakpointSpec parses "file.php:42" or "file.php:42,55,60" +func ParseBreakpointSpec(spec string) (file string, lines []int, err error) { + parts := strings.Split(spec, ":") + if len(parts) != 2 { + return "", nil, fmt.Errorf("invalid breakpoint spec: %s (expected file:line)", spec) + } + + file = parts[0] + lineStrs := strings.Split(parts[1], ",") + lines = make([]int, 0, len(lineStrs)) + + for _, ls := range lineStrs { + l, err := strconv.Atoi(strings.TrimSpace(ls)) + if err != nil { + return "", nil, fmt.Errorf("invalid line number: %s", ls) + } + lines = append(lines, l) + } + + return file, lines, nil +} diff --git a/cli/dbgp/real_test.go b/cli/dbgp/real_test.go new file mode 100644 index 0000000..0689948 --- /dev/null +++ b/cli/dbgp/real_test.go @@ -0,0 +1,24 @@ +package dbgp + +import ( + "fmt" + "testing" +) + +func TestParseRealXML(t *testing.T) { + xml := `` + + vars := ParseVariables(xml) + fmt.Printf("Found %d variables:\n", len(vars)) + for i, v := range vars { + fmt.Printf("[%d] %s (%s) = %q\n", i, v.Name, v.Type, v.Value) + } + + if len(vars) != 3 { + t.Fatalf("expected 3 variables, got %d", len(vars)) + } + + if vars[0].Value != `"Hello, World!"` { + t.Errorf("$count: expected %q, got %q", `"Hello, World!"`, vars[0].Value) + } +} diff --git a/cli/dbgp/testdata/context_get_complex.xml b/cli/dbgp/testdata/context_get_complex.xml new file mode 100644 index 0000000..ca1fdc0 --- /dev/null +++ b/cli/dbgp/testdata/context_get_complex.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..d458d6d --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,3 @@ +module github.com/pronskiy/php-debugger/cli + +go 1.21 diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..029090f --- /dev/null +++ b/cli/main.go @@ -0,0 +1,497 @@ +package main + +import ( + "bufio" + "context" + "flag" + "fmt" + "io" + "net" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/pronskiy/php-debugger/cli/dbgp" +) + +// breakpointFlag implements flag.Value for multiple -breakpoint flags +type breakpointFlag struct { + file string + lines []int +} + +type breakpointFlags []breakpointFlag + +func (b *breakpointFlags) String() string { + return fmt.Sprintf("%v", *b) +} + +func (b *breakpointFlags) Set(value string) error { + parts := strings.Split(value, ":") + if len(parts) != 2 { + return fmt.Errorf("invalid breakpoint format: %s (expected file:line[,line,...])", value) + } + file := parts[0] + var lines []int + for _, ls := range strings.Split(parts[1], ",") { + l, err := strconv.Atoi(strings.TrimSpace(ls)) + if err != nil { + return fmt.Errorf("invalid line number: %s", ls) + } + lines = append(lines, l) + } + *b = append(*b, breakpointFlag{file: file, lines: lines}) + return nil +} + +var ( + flagPort int + flagTimeout time.Duration + flagBreakpoint breakpointFlags + flagRaw bool +) + +func init() { + flag.IntVar(&flagPort, "port", 9003, "TCP port for DBGp protocol connection") + flag.DurationVar(&flagTimeout, "timeout", 30*time.Second, "Maximum time to wait for PHP connection") + flag.Var(&flagBreakpoint, "breakpoint", "Set breakpoint (can be repeated). Format: file.php:line or file.php:line1,line2,line3") + flag.BoolVar(&flagRaw, "raw", false, "Output raw XML responses below variables") +} + +type Debugger struct { + listener net.Listener + conn net.Conn + reader *bufio.Reader + writer io.Writer + + transID int + initPacket string + fileURI string +} + +func NewDebugger(port int) (*Debugger, error) { + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return nil, err + } + return &Debugger{listener: listener}, nil +} + +func (d *Debugger) Addr() string { + return d.listener.Addr().String() +} + +func (d *Debugger) WaitForConnection(timeout time.Duration) error { + tcpListener := d.listener.(*net.TCPListener) + tcpListener.SetDeadline(time.Now().Add(timeout)) + + conn, err := d.listener.Accept() + if err != nil { + return err + } + + d.conn = conn + d.reader = bufio.NewReader(conn) + d.writer = conn + + // Read init packet + initData, err := d.readPacket() + if err != nil { + return fmt.Errorf("read init: %w", err) + } + + d.initPacket = string(initData) + + // Parse fileuri from init + if strings.Contains(d.initPacket, "fileuri=") { + start := strings.Index(d.initPacket, `fileuri="`) + if start != -1 { + start += 9 + end := strings.Index(d.initPacket[start:], `"`) + if end != -1 { + d.fileURI = d.initPacket[start : start+end] + } + } + } + + return nil +} + +func (d *Debugger) readPacket() ([]byte, error) { + line, err := d.reader.ReadString('\x00') + if err != nil { + return nil, err + } + + line = strings.TrimSuffix(line, "\x00") + length, err := strconv.Atoi(line) + if err != nil { + return nil, fmt.Errorf("parse length: %w", err) + } + + data := make([]byte, length) + _, err = io.ReadFull(d.reader, data) + if err != nil { + return nil, err + } + + b, err := d.reader.ReadByte() + if err != nil { + return nil, err + } + if b != '\x00' { + return nil, fmt.Errorf("expected NULL, got %x", b) + } + + return data, nil +} + +func (d *Debugger) sendCommand(cmd string) (string, error) { + // Check if command already has -i + if !strings.Contains(cmd, "-i ") { + d.transID++ + cmd = fmt.Sprintf("%s -i %d", cmd, d.transID) + } else { + parts := strings.Split(cmd, "-i ") + if len(parts) > 1 { + idStr := strings.Fields(parts[1])[0] + id, _ := strconv.Atoi(idStr) + d.transID = id + } + } + + packet := fmt.Sprintf("%d\x00%s\x00", len(cmd), cmd) + _, err := d.writer.Write([]byte(packet)) + if err != nil { + return "", err + } + + // Read response (skip async responses without transaction_id) + for { + respData, err := d.readPacket() + if err != nil { + return "", err + } + + resp := string(respData) + expectedID := fmt.Sprintf(`transaction_id="%d"`, d.transID) + if strings.Contains(resp, expectedID) { + return resp, nil + } + } +} + +func (d *Debugger) Close() { + if d.conn != nil { + d.conn.Close() + } + if d.listener != nil { + d.listener.Close() + } +} + +func main() { + flag.Usage = func() { + fmt.Fprintf(os.Stderr, `PHP Debugger CLI - DBGp Protocol Debug Tool + +DESCRIPTION + A command-line debugger for PHP applications using the DBGp protocol. + Listens for incoming connections from PHP's Xdebug or php-debugger extension + to set breakpoints, inspect variables, and step through code execution. + +USAGE + dbg [options] + +OPTIONS + -port TCP port for DBGp connection (default: 9003) + -timeout Time to wait for PHP connection before exiting (default: 30s) + -breakpoint Set breakpoint, can be repeated (see format below) + -raw Output raw XML responses below parsed variables + +BREAKPOINT FORMAT + file.php:42 Single breakpoint at line 42 + file.php:10,20,30 Multiple lines in same file + path/to/file.php:100 With relative or absolute path + + The -breakpoint flag can be specified multiple times: + dbg -breakpoint app.php:10 -breakpoint lib.php:20 + +EXAMPLES + # Listen for connections with breakpoints set + dbg -breakpoint app.php:42 + + # Multiple breakpoints in different files + dbg -breakpoint Controller.php:10,20,30 -breakpoint Model.php:50 + + # Custom port + dbg -port 9003 + + # With raw XML output for debugging + dbg -raw -breakpoint app.php:42 + +RUNNING PHP SCRIPTS + Optionally, you can specify a PHP script to run automatically: + + dbg -breakpoint app.php:42 app.php + dbg -breakpoint Controller.php:10 app.php [script args...] + + This sets XDEBUG_SESSION=1 and XDEBUG_CONFIG=client_port= before + running the script. For web requests or external processes, omit the script. + +PROTOCOL + Uses DBGp (Debug Generic Protocol) - the same protocol used by Xdebug. + PHP connects as a client to this debugger which acts as a server. + +`) + } + flag.Parse() + + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigChan + fmt.Println("\nInterrupted") + cancel() + }() + + dbg, err := NewDebugger(flagPort) + if err != nil { + return fmt.Errorf("create listener: %w", err) + } + defer dbg.Close() + + fmt.Printf("Listening on %s\n", dbg.Addr()) + + // Parse breakpoints from flags + var breakpoints []breakpoint + for _, bp := range flagBreakpoint { + for _, line := range bp.lines { + breakpoints = append(breakpoints, breakpoint{file: bp.file, line: line}) + } + } + + // Get PHP script from args + args := flag.Args() + var phpScript string + var phpArgs []string + for i, arg := range args { + if strings.HasSuffix(arg, ".php") { + phpScript = arg + phpArgs = args[i+1:] + break + } + } + + // Start PHP if script provided + var phpCmd *exec.Cmd + if phpScript != "" { + if !filepath.IsAbs(phpScript) { + phpScript, _ = filepath.Abs(phpScript) + } + phpCmd = exec.CommandContext(ctx, "php", append([]string{phpScript}, phpArgs...)...) + phpCmd.Env = append(os.Environ(), + "XDEBUG_SESSION=1", + fmt.Sprintf("XDEBUG_CONFIG=client_port=%d", flagPort), + ) + phpCmd.Stdout = os.Stdout + phpCmd.Stderr = os.Stderr + } + + if phpScript != "" { + fmt.Printf("Starting: php %s\n", phpScript) + } else { + fmt.Println("Waiting for PHP to connect...") + } + + // Wait for connection + connChan := make(chan error, 1) + go func() { + connChan <- dbg.WaitForConnection(flagTimeout) + }() + + if phpCmd != nil { + time.Sleep(100 * time.Millisecond) + if err := phpCmd.Start(); err != nil { + return fmt.Errorf("start PHP: %w", err) + } + } + + select { + case err := <-connChan: + if err != nil { + return fmt.Errorf("connection: %w", err) + } + case <-ctx.Done(): + return ctx.Err() + } + + fmt.Printf("\n✓ Connected: %s\n", strings.TrimPrefix(dbg.fileURI, "file://")) + + // Enable features + dbg.sendCommand("feature_set -n resolved_breakpoints -v 1") + + // Set breakpoints + for _, bp := range breakpoints { + bpFile := bp.file + if !filepath.IsAbs(bpFile) { + bpFile, _ = filepath.Abs(bpFile) + } + cmd := fmt.Sprintf("breakpoint_set -t line -f file://%s -n %d", bpFile, bp.line) + resp, err := dbg.sendCommand(cmd) + if err != nil { + fmt.Printf("✗ %s:%d: %v\n", bpFile, bp.line, err) + continue + } + if strings.Contains(resp, "error") { + fmt.Printf("✗ %s:%d: error\n", bpFile, bp.line) + } else { + fmt.Printf("✓ Breakpoint: %s:%d\n", filepath.Base(bpFile), bp.line) + } + } + + // Run and wait for breakpoint + fmt.Println("\n→ Running...") + + resp, err := dbg.sendCommand("run") + if err != nil { + return fmt.Errorf("run: %w", err) + } + + // Process breakpoints + for strings.Contains(resp, `status="break"`) { + file, line := parseMessage(resp) + fmt.Printf("\n━━━ %s:%d ━━━\n", filepath.Base(file), line) + + // Get stack + stackResp, _ := dbg.sendCommand("stack_get") + fmt.Println("\nStack:") + printStack(stackResp) + + // Get variables + varsResp, _ := dbg.sendCommand("context_get -d 0 -c 0") + vars := dbgp.ParseVariables(varsResp) + fmt.Println("\nVariables:") + for _, v := range vars { + fmt.Println(dbgp.FormatVariable(v)) + } + + // Show raw XML if requested + if flagRaw { + fmt.Println("\nRaw XML:") + fmt.Println(varsResp) + } + + // Continue + resp, err = dbg.sendCommand("run") + if err != nil { + return err + } + } + + if strings.Contains(resp, `status="stopping"`) || strings.Contains(resp, `status="stopped"`) { + fmt.Println("\n✓ Done") + } + + if phpCmd != nil { + phpCmd.Wait() + } + + return nil +} + +type breakpoint struct { + file string + line int +} + +func parseMessage(resp string) (string, int) { + fileIdx := strings.Index(resp, `filename="`) + if fileIdx == -1 { + return "", 0 + } + fileIdx += 10 + fileEnd := strings.Index(resp[fileIdx:], `"`) + file := resp[fileIdx : fileIdx+fileEnd] + file = strings.TrimPrefix(file, "file://") + + lineIdx := strings.Index(resp, `lineno="`) + if lineIdx == -1 { + return file, 0 + } + lineIdx += 8 + lineEnd := strings.Index(resp[lineIdx:], `"`) + line, _ := strconv.Atoi(resp[lineIdx : lineIdx+lineEnd]) + + return file, line +} + +func printStack(resp string) { + for { + stackIdx := strings.Index(resp, ``) + if endIdx == -1 { + endIdx = strings.Index(resp[stackIdx:], `/>`) + if endIdx == -1 { + break + } + endIdx += 2 + } else { + endIdx += 9 + } + + stack := resp[stackIdx : stackIdx+endIdx] + resp = resp[stackIdx+endIdx:] + + level := extractAttr(stack, "level") + where := extractAttr(stack, "where") + filename := extractAttr(stack, "filename") + lineno := extractAttr(stack, "lineno") + + filename = strings.TrimPrefix(filename, "file://") + + if level != "" { + fmt.Printf("#%s %s() at %s:%s\n", level, unescapeXML(where), filepath.Base(filename), lineno) + } + } +} + +func extractAttr(s, name string) string { + pattern := fmt.Sprintf(`%s="`, name) + idx := strings.Index(s, pattern) + if idx == -1 { + return "" + } + idx += len(pattern) + end := strings.Index(s[idx:], `"`) + if end == -1 { + return "" + } + return s[idx : idx+end] +} + +func unescapeXML(s string) string { + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, """, `"`) + s = strings.ReplaceAll(s, "&", "&") + return s +}