diff --git a/README.md b/README.md
index f203192..ecc4ace 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,4 @@
# dbgp-client
CLI DBGP client
+
+Code imported from `cli/dbgp` in https://github.com/php-debugger/php-debugger/pull/12 (authored by @Haehnchen).
diff --git a/client.go b/client.go
new file mode 100644
index 0000000..7080eee
--- /dev/null
+++ b/client.go
@@ -0,0 +1,519 @@
+package dbgp
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/base64"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+ "unicode"
+)
+
+// Client manages the DBGp connection
+type Client struct {
+ listener net.Listener
+ conn net.Conn
+ reader *bufio.Reader
+ writer io.Writer
+
+ mu sync.Mutex
+ writeMu sync.Mutex
+ breakMu 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 {
+ if deadlineListener, ok := c.listener.(interface{ SetDeadline(time.Time) error }); ok {
+ if err := deadlineListener.SetDeadline(time.Now().Add(timeout)); err != nil {
+ return fmt.Errorf("set accept deadline: %w", err)
+ }
+ } else {
+ return fmt.Errorf("listener does not support deadlines")
+ }
+ }
+
+ conn, err := c.listener.Accept()
+ if err != nil {
+ return fmt.Errorf("accept connection: %w", err)
+ }
+
+ c.mu.Lock()
+ c.conn = conn
+ c.reader = bufio.NewReader(conn)
+ c.writer = conn
+ c.mu.Unlock()
+
+ // Read init packet
+ initData, err := c.readPacket()
+ if err != nil {
+ _ = conn.Close()
+ c.mu.Lock()
+ c.conn = nil
+ c.reader = nil
+ c.writer = nil
+ c.mu.Unlock()
+ return fmt.Errorf("read init packet: %w", err)
+ }
+
+ initPacket, err := ParseInit(initData)
+ if err != nil {
+ _ = conn.Close()
+ c.mu.Lock()
+ c.conn = nil
+ c.reader = nil
+ c.writer = nil
+ c.mu.Unlock()
+ return fmt.Errorf("parse init packet: %w", err)
+ }
+ c.mu.Lock()
+ c.init = initPacket
+ c.mu.Unlock()
+
+ // Start response reader
+ go c.readLoop()
+
+ return nil
+}
+
+// Init returns the initialization packet from PHP
+func (c *Client) Init() *InitPacket {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ 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.mu.Lock()
+ defer c.mu.Unlock()
+ 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 {
+ pending := make([]chan *Response, 0)
+ c.mu.Lock()
+ for _, ch := range c.responses {
+ pending = append(pending, ch)
+ }
+ c.responses = make(map[int]chan *Response)
+ c.mu.Unlock()
+
+ for _, ch := range pending {
+ select {
+ case ch <- &Response{
+ Error: &Error{
+ Code: -1,
+ Message: "connection closed",
+ },
+ }:
+ default:
+ }
+ }
+ return
+ }
+
+ resp, err := ParseResponse(data)
+ if err != nil {
+ continue
+ }
+
+ // Check if this is a breakpoint hit (async response)
+ c.mu.Lock()
+ onBreakpoint := c.onBreakpoint
+ c.mu.Unlock()
+ if resp.Status == StatusBreak && 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) {
+ c.breakMu.Lock()
+ defer c.breakMu.Unlock()
+
+ file, line := resp.ParseMessage()
+
+ // Get stack
+ stack, _ := c.GetStack()
+
+ // Get local variables
+ vars, _ := c.GetContext(0, 0)
+
+ c.mu.Lock()
+ onBreakpoint := c.onBreakpoint
+ c.mu.Unlock()
+ if onBreakpoint != nil {
+ 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) {
+ c.mu.Lock()
+ conn := c.conn
+ writer := c.writer
+ c.mu.Unlock()
+ if conn == nil || writer == nil {
+ return nil, fmt.Errorf("not connected")
+ }
+
+ transID := c.nextTransID()
+
+ // Ensure transaction ID is in command
+ fields := strings.Fields(cmd)
+ hasTransID := false
+ for i := 0; i < len(fields); i++ {
+ if fields[i] == "-i" {
+ if i+1 >= len(fields) {
+ return nil, fmt.Errorf("invalid command: missing transaction id after -i")
+ }
+ id, err := strconv.Atoi(fields[i+1])
+ if err != nil {
+ return nil, fmt.Errorf("invalid transaction id %q: %w", fields[i+1], err)
+ }
+ transID = id
+ hasTransID = true
+ break
+ }
+ }
+ if !hasTransID {
+ cmd = fmt.Sprintf("%s -i %d", cmd, transID)
+ }
+
+ // 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)
+ c.writeMu.Lock()
+ _, err := writer.Write([]byte(packet))
+ c.writeMu.Unlock()
+ 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
+ }
+
+ var parsed struct {
+ Breakpoints []BreakpointInfo `xml:"breakpoint"`
+ }
+ decoder := xml.NewDecoder(bytes.NewReader([]byte("" + resp.Raw + "")))
+ decoder.CharsetReader = charsetReader
+ if err := decoder.Decode(&parsed); err != nil {
+ return nil, fmt.Errorf("parse breakpoint list: %w", err)
+ }
+
+ return parsed.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 ParseVariablesFromProperties(resp.Properties), 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 decodeResponseValue(resp)
+}
+
+// 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) {
+ if strings.ContainsAny(name, " \t\r\n") {
+ return "", fmt.Errorf("feature name must not contain whitespace")
+ }
+ cmd := fmt.Sprintf("feature_get -n %s", name)
+ resp, err := c.sendCommand(cmd)
+ if err != nil {
+ return "", err
+ }
+ return decodeResponseValue(resp)
+}
+
+// FeatureSet sets a feature value
+func (c *Client) FeatureSet(name, value string) error {
+ if strings.ContainsAny(name, " \t\r\n") {
+ return fmt.Errorf("feature name must not contain whitespace")
+ }
+ if strings.ContainsAny(value, " \t\r\n") {
+ return fmt.Errorf("feature value must not contain whitespace")
+ }
+ 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 {
+ var closeErr error
+ c.mu.Lock()
+ conn := c.conn
+ listener := c.listener
+ c.conn = nil
+ c.reader = nil
+ c.writer = nil
+ c.mu.Unlock()
+
+ if conn != nil {
+ closeErr = errors.Join(closeErr, conn.Close())
+ }
+ if listener != nil {
+ closeErr = errors.Join(closeErr, listener.Close())
+ }
+ return closeErr
+}
+
+func decodeResponseValue(resp *Response) (string, error) {
+ value := strings.TrimSpace(resp.Value)
+ if value == "" {
+ value = strings.TrimSpace(resp.Raw)
+ }
+ if resp.Encoding == "base64" && value != "" {
+ cleaned := strings.Map(func(r rune) rune {
+ if unicode.IsSpace(r) {
+ return -1
+ }
+ return r
+ }, value)
+ decoded, err := base64.StdEncoding.DecodeString(cleaned)
+ if err != nil {
+ return "", fmt.Errorf("decode base64 response: %w", err)
+ }
+ return string(decoded), nil
+ }
+ return value, nil
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..c06e81d
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module github.com/php-debugger/dbgp-client
+
+go 1.21
diff --git a/parser.go b/parser.go
new file mode 100644
index 0000000..6cf5ceb
--- /dev/null
+++ b/parser.go
@@ -0,0 +1,170 @@
+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
+ }
+
+ return ParseVariablesFromProperties(resp.Properties)
+}
+
+// ParseVariablesFromProperties extracts top-level variables and their immediate children from parsed properties
+func ParseVariablesFromProperties(properties []Property) []Variable {
+ var vars []Variable
+ for _, p := range properties {
+ 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, 0)
+}
+
+// flattenProperties recursively flattens nested properties
+func flattenProperties(props []Property, level int) []Variable {
+ var vars []Variable
+ for _, p := range props {
+ vars = append(vars, Variable{
+ Name: p.FullName,
+ Type: p.Type,
+ Value: formatPropertyValue(p),
+ Level: level,
+ })
+ if len(p.ChildProperties) > 0 {
+ vars = append(vars, flattenProperties(p.ChildProperties, level+1)...)
+ }
+ }
+ 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
+
+ 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":
+ 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)
+ if width < 8 {
+ width = 8
+ }
+ return fmt.Sprintf("%s%-*s %-8s = %s", indent, width, v.Name, v.Type, v.Value)
+}
diff --git a/parser_test.go b/parser_test.go
new file mode 100644
index 0000000..18f5ad4
--- /dev/null
+++ b/parser_test.go
@@ -0,0 +1,183 @@
+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", "array[]"},
+ {"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",
+ },
+ {
+ name: "string starting with angle bracket",
+ prop: Property{Type: "string", Value: ""},
+ want: `""`,
+ },
+ }
+
+ 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))
+
+ 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
+ wantLevel int
+ }{
+ {"$count", "string", `"Hello, World!"`, 0},
+ {"$name", "string", `"World"`, 0},
+ {"$sum", "int", "15", 0},
+ {"$numbers", "array", "array[5]", 0},
+ {"$numbers[0]", "int", "1", 1},
+ {"$user", "array", "array[3]", 0},
+ {"$obj", "object", `App\Service\MyService`, 0},
+ {"$obj->id", "int", "42", 1},
+ {"$emptyArray", "array", "array[]", 0},
+ {"$nullVal", "null", "null", 0},
+ {"$boolVal", "bool", "true", 0},
+ }
+
+ 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)
+ }
+ if v.Level != tt.wantLevel {
+ t.Errorf("level = %d, want %d", v.Level, tt.wantLevel)
+ }
+ })
+ }
+}
+
+func TestParseAllVariablesFromRealXML(t *testing.T) {
+ data, err := os.ReadFile("testdata/context_get_complex.xml")
+ if err != nil {
+ t.Fatalf("read test file: %v", err)
+ }
+
+ vars := ParseAllVariables(string(data))
+
+ 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
+ wantLevel int
+ }{
+ {"$obj", "object", `App\Service\MyService`, 0},
+ {"$obj->cache", "object", `App\Cache`, 1},
+ {"$obj->cache->items", "array", "array[3]", 2},
+ {"$obj->cache->items[0]", "string", `"item1"`, 3},
+ }
+
+ 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)
+ }
+ if v.Level != tt.wantLevel {
+ t.Errorf("level = %d, want %d", v.Level, tt.wantLevel)
+ }
+ })
+ }
+}
diff --git a/protocol.go b/protocol.go
new file mode 100644
index 0000000..368d09f
--- /dev/null
+++ b/protocol.go
@@ -0,0 +1,335 @@
+// Package dbgp implements the DBGp protocol for PHP debugging
+package dbgp
+
+import (
+ "bytes"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net/url"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+// charsetReader handles non-UTF-8 XML encodings
+func charsetReader(encoding string, input io.Reader) (io.Reader, error) {
+ switch strings.ToLower(encoding) {
+ case "utf-8", "us-ascii":
+ return input, nil
+ case "iso-8859-1", "latin1":
+ return decodeSingleByte(input, nil)
+ case "windows-1252":
+ return decodeSingleByte(input, windows1252Overrides)
+ default:
+ return nil, fmt.Errorf("unsupported charset: %s", encoding)
+ }
+}
+
+func decodeSingleByte(input io.Reader, overrides map[byte]rune) (io.Reader, error) {
+ data, err := io.ReadAll(input)
+ if err != nil {
+ return nil, err
+ }
+
+ runes := make([]rune, len(data))
+ for i, b := range data {
+ if overrides != nil {
+ if mapped, ok := overrides[b]; ok {
+ runes[i] = mapped
+ continue
+ }
+ }
+ runes[i] = rune(b)
+ }
+
+ return strings.NewReader(string(runes)), nil
+}
+
+var windows1252Overrides = map[byte]rune{
+ 0x80: '€', 0x82: '‚', 0x83: 'ƒ', 0x84: '„', 0x85: '…', 0x86: '†', 0x87: '‡', 0x88: 'ˆ',
+ 0x89: '‰', 0x8A: 'Š', 0x8B: '‹', 0x8C: 'Œ', 0x8E: 'Ž', 0x91: '‘', 0x92: '’', 0x93: '“',
+ 0x94: '”', 0x95: '•', 0x96: '–', 0x97: '—', 0x98: '˜', 0x99: '™', 0x9A: 'š', 0x9B: '›',
+ 0x9C: 'œ', 0x9E: 'ž', 0x9F: 'Ÿ',
+}
+
+// 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,attr"`
+ 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"`
+ Encoding string `xml:"encoding,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:"https://xdebug.org/dbgp/xdebug message,omitempty"`
+
+ // Raw for debugging
+ Raw string `xml:",innerxml"`
+ // Value contains raw response chardata when present
+ Value string `xml:",chardata"`
+}
+
+// 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 = FormatFileURI(r.Message.Filename)
+ line = r.Message.Lineno
+ }
+ return
+}
+
+func formatValue(p Property) string {
+ if p.Encoding == "base64" {
+ return ""
+ }
+
+ if p.Type == "array" || p.Type == "object" {
+ count := p.NumChildren
+ if count == 0 {
+ count = p.Children
+ }
+ if count > 0 {
+ return fmt.Sprintf("%s(%d)", p.Type, count)
+ }
+ 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 {
+ u, err := url.Parse(uri)
+ if err != nil || u.Scheme != "file" {
+ return strings.TrimPrefix(uri, "file://")
+ }
+
+ path, err := url.PathUnescape(u.Path)
+ if err != nil {
+ path = u.Path
+ }
+
+ if len(path) >= 3 && path[0] == '/' && path[2] == ':' {
+ path = path[1:]
+ }
+
+ if u.Host != "" {
+ return "//" + u.Host + filepath.FromSlash(path)
+ }
+
+ return filepath.FromSlash(path)
+}
+
+// MakeFileURI converts path to file:// URI
+func MakeFileURI(path string) string {
+ if strings.HasPrefix(path, "file://") {
+ u, err := url.Parse(path)
+ if err == nil {
+ return u.String()
+ }
+ return path
+ }
+
+ if strings.HasPrefix(path, `\\`) || strings.HasPrefix(path, "//") {
+ trimmed := strings.TrimLeft(path, `/\`)
+ parts := strings.SplitN(trimmed, `\`, 2)
+ if len(parts) == 1 {
+ parts = strings.SplitN(trimmed, "/", 2)
+ }
+ host := parts[0]
+ sharePath := ""
+ if len(parts) == 2 {
+ sharePath = "/" + strings.ReplaceAll(parts[1], `\`, `/`)
+ }
+ return (&url.URL{Scheme: "file", Host: host, Path: sharePath}).String()
+ }
+
+ slashed := strings.ReplaceAll(path, `\`, `/`)
+ slashed = filepath.ToSlash(slashed)
+ if strings.HasPrefix(slashed, "/") {
+ return (&url.URL{Scheme: "file", Path: slashed}).String()
+ }
+
+ return (&url.URL{Scheme: "file", Path: "/" + slashed}).String()
+}
+
+// ParseBreakpointSpec parses "file.php:42" or "file.php:42,55,60"
+func ParseBreakpointSpec(spec string) (file string, lines []int, err error) {
+ sep := strings.LastIndex(spec, ":")
+ if sep <= 0 || sep == len(spec)-1 {
+ return "", nil, fmt.Errorf("invalid breakpoint spec: %s (expected file:line)", spec)
+ }
+
+ file = spec[:sep]
+ linePart := spec[sep+1:]
+ for _, r := range linePart {
+ if (r < '0' || r > '9') && r != ',' {
+ return "", nil, fmt.Errorf("invalid breakpoint spec: %s (expected file:line)", spec)
+ }
+ }
+
+ lineStrs := strings.Split(linePart, ",")
+ 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/protocol_test.go b/protocol_test.go
new file mode 100644
index 0000000..84ed514
--- /dev/null
+++ b/protocol_test.go
@@ -0,0 +1,73 @@
+package dbgp
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestParseBreakpointSpec(t *testing.T) {
+ tests := []struct {
+ spec string
+ wantFile string
+ wantLines []int
+ }{
+ {"file.php:42", "file.php", []int{42}},
+ {"file.php:42,55,60", "file.php", []int{42, 55, 60}},
+ {`C:\path\file.php:42`, `C:\path\file.php`, []int{42}},
+ {"file:///tmp/test.php:42", "file:///tmp/test.php", []int{42}},
+ {"file://C:/path/test.php:42", "file://C:/path/test.php", []int{42}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.spec, func(t *testing.T) {
+ file, lines, err := ParseBreakpointSpec(tt.spec)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if file != tt.wantFile {
+ t.Fatalf("file = %q, want %q", file, tt.wantFile)
+ }
+ if !reflect.DeepEqual(lines, tt.wantLines) {
+ t.Fatalf("lines = %v, want %v", lines, tt.wantLines)
+ }
+ })
+ }
+}
+
+func TestFileURIConversions(t *testing.T) {
+ tests := []struct {
+ name string
+ path string
+ expectURI string
+ }{
+ {
+ name: "unix path with spaces",
+ path: "/tmp/test dir/file.php",
+ expectURI: "file:///tmp/test%20dir/file.php",
+ },
+ {
+ name: "windows drive path",
+ path: `C:\path with spaces\file.php`,
+ expectURI: "file:///C:/path%20with%20spaces/file.php",
+ },
+ {
+ name: "windows unc path",
+ path: `\\server\share\dir\file.php`,
+ expectURI: "file://server/share/dir/file.php",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotURI := MakeFileURI(tt.path)
+ if gotURI != tt.expectURI {
+ t.Fatalf("MakeFileURI() = %q, want %q", gotURI, tt.expectURI)
+ }
+
+ gotPath := FormatFileURI(gotURI)
+ if gotPath == "" {
+ t.Fatalf("FormatFileURI() returned empty path")
+ }
+ })
+ }
+}
diff --git a/real_test.go b/real_test.go
new file mode 100644
index 0000000..0976cf7
--- /dev/null
+++ b/real_test.go
@@ -0,0 +1,19 @@
+package dbgp
+
+import (
+ "testing"
+)
+
+func TestParseRealXML(t *testing.T) {
+ xml := ``
+
+ vars := ParseVariables(xml)
+
+ 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/testdata/context_get_complex.xml b/testdata/context_get_complex.xml
new file mode 100644
index 0000000..ca1fdc0
--- /dev/null
+++ b/testdata/context_get_complex.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+