Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions modules/xdebug/breakpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ type SavedBreakpoint struct {
Type string `json:"type"` // line or exception
File string `json:"file,omitempty"` // file:// URI as PHP sees it (line)
Line int `json:"line,omitempty"` // line
Exception string `json:"exception,omitempty"` // class name, e.g. \Throwable (exception)
Exception string `json:"exception,omitempty"` // class name as PHP holds it, e.g. Throwable (exception)
Condition string `json:"condition,omitempty"` // PHP expression; sent only with allow_eval
Enabled bool `json:"enabled"`
Source string `json:"source"` // ui or mcp
Expand All @@ -50,7 +50,9 @@ func (b *SavedBreakpoint) normalize() error {
}
}
b.File = fileURI(strings.TrimSpace(b.File))
b.Exception = strings.TrimSpace(b.Exception)
// Xdebug matches the class name as PHP holds it, with no leading \;
// "\RuntimeException" would be accepted and never fire.
b.Exception = strings.TrimPrefix(strings.TrimSpace(b.Exception), `\`)
switch b.Type {
case BPLine:
if b.File == "" || b.Line < 1 {
Expand All @@ -62,7 +64,7 @@ func (b *SavedBreakpoint) normalize() error {
b.Exception = ""
case BPException:
if b.Exception == "" {
return errors.New("an exception breakpoint needs exception (a class name, e.g. \\Throwable)")
return errors.New("an exception breakpoint needs exception (a class name, e.g. Throwable)")
}
b.File, b.Line = "", 0
default:
Expand Down
28 changes: 25 additions & 3 deletions modules/xdebug/breakpoints_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ func TestBreakpoints_SentAtStartWithState(t *testing.T) {
waitFor(t, "break", s.Paused)

sets := be.commands("breakpoint_set")
if len(sets) != 2 || !strings.Contains(sets[0], "-t line -f file:///app/src/User.php -n 20") || !strings.Contains(sets[1], `-t exception -x "\\Throwable"`) {
if len(sets) != 2 || !strings.Contains(sets[0], "-t line -f file:///app/src/User.php -n 20") || !strings.Contains(sets[1], `-t exception -x Throwable`) {
t.Fatalf("breakpoint_set: %q", sets)
}
// Breakpoints go out before the script runs.
Expand All @@ -147,7 +147,7 @@ func TestBreakpoints_SentAtStartWithState(t *testing.T) {
for _, want := range []string{
`"id":` + itoa(int(line.ID)) + `,"type":"line","file":"file:///app/src/User.php","line":20,"enabled":true,"source":"ui"`,
`"state":"resolved","resolved_line":21,"hit_count":2`,
`"id":` + itoa(int(exc.ID)) + `,"type":"exception","exception":"\\Throwable"`,
`"id":` + itoa(int(exc.ID)) + `,"type":"exception","exception":"Throwable"`,
`"id":` + itoa(int(off.ID)) + `,"type":"line","file":"file:///app/src/Off.php","line":3,"enabled":false`,
`"state":"disabled"`,
`"id":` + itoa(int(cond.ID)),
Expand Down Expand Up @@ -246,7 +246,7 @@ func TestBreakpoints_API(t *testing.T) {
if code, body := a.do(t, "POST", "/api/xdebug/breakpoints", `{"exception":"\\RuntimeException"}`); code != http.StatusCreated || !strings.Contains(body, `"type":"exception"`) {
t.Fatalf("exception: %d %s", code, body)
}
if code, body := a.do(t, "GET", "/api/xdebug/breakpoints", ""); code != http.StatusOK || !strings.Contains(body, `"exception":"\\RuntimeException"`) {
if code, body := a.do(t, "GET", "/api/xdebug/breakpoints", ""); code != http.StatusOK || !strings.Contains(body, `"exception":"RuntimeException"`) {
t.Fatalf("list: %d %s", code, body)
}
if code, _ := a.do(t, "PATCH", "/api/xdebug/breakpoints/99", `{"enabled":false}`); code != http.StatusNotFound {
Expand Down Expand Up @@ -538,3 +538,25 @@ func TestBreakpoints_ListResolves(t *testing.T) {
t.Fatalf("%+v", bps)
}
}

// Xdebug matches an exception breakpoint by the class name as PHP holds it:
// "\RuntimeException" is accepted by the engine and never fires.
func TestBreakpoints_ExceptionLeadingBackslash(t *testing.T) {
for in, want := range map[string]string{
`\RuntimeException`: `RuntimeException`,
` \App\Errors\Payment `: `App\Errors\Payment`,
`RuntimeException`: `RuntimeException`,
`*`: `*`,
} {
b := SavedBreakpoint{Exception: in}
if err := b.normalize(); err != nil || b.Type != BPException || b.Exception != want {
t.Errorf("%q: got %q type %q err %v, want %q", in, b.Exception, b.Type, err, want)
}
if cmd := breakpointSetCmd(b); !strings.HasSuffix(cmd, " -x "+quoteArg(want)) {
t.Errorf("%q: command %q", in, cmd)
}
}
if b := (SavedBreakpoint{Exception: `\`}); b.normalize() == nil {
t.Error(`a lone \ must be rejected`)
}
}
2 changes: 1 addition & 1 deletion modules/xdebug/control_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ func (s *Session) pausedAtOf() time.Time {
type bpSetInput struct {
File string `json:"file,omitempty" jsonschema:"File as PHP sees it: an absolute path (/var/www/app/src/User.php) or a file:// URI. Paths on the PHP side, not your local checkout, unless they are the same."`
Line int `json:"line,omitempty" jsonschema:"Line number (1-based) for a line breakpoint"`
Exception string `json:"exception,omitempty" jsonschema:"Exception class for an exception breakpoint instead of file+line, e.g. \\Throwable or \\App\\Exceptions\\PaymentFailed"`
Exception string `json:"exception,omitempty" jsonschema:"Exception class for an exception breakpoint instead of file+line, e.g. Throwable or App\\Exceptions\\PaymentFailed (a leading \\ is dropped)"`
Condition string `json:"condition,omitempty" jsonschema:"PHP expression; stop only when it is true. Needs xdebug.allow_eval."`
}

Expand Down
Loading