From e9b7a17fe4e2f5c6d2fe46437042739c33a69d9f Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Sat, 13 Jun 2026 10:00:11 -0400 Subject: [PATCH 1/6] Add interactive debugger and CLI tools to Ferret --- Makefile | 2 +- README.md | 45 +++++ cmd/debug.go | 87 ++++++++++ cmd/debug_test.go | 88 ++++++++++ ferret/main.go | 1 + internal/debugcli/command.go | 215 ++++++++++++++++++++++++ internal/debugcli/command_test.go | 159 ++++++++++++++++++ internal/debugcli/prompt.go | 158 ++++++++++++++++++ internal/debugcli/prompt_test.go | 265 ++++++++++++++++++++++++++++++ internal/debugcli/render.go | 241 +++++++++++++++++++++++++++ internal/debugcli/render_test.go | 135 +++++++++++++++ internal/debugcli/session.go | 27 +++ pkg/runtime/builtin.go | 4 + pkg/runtime/debug.go | 77 +++++++++ pkg/runtime/debug_test.go | 162 ++++++++++++++++++ pkg/runtime/errors.go | 12 ++ 16 files changed, 1677 insertions(+), 1 deletion(-) create mode 100644 cmd/debug.go create mode 100644 cmd/debug_test.go create mode 100644 internal/debugcli/command.go create mode 100644 internal/debugcli/command_test.go create mode 100644 internal/debugcli/prompt.go create mode 100644 internal/debugcli/prompt_test.go create mode 100644 internal/debugcli/render.go create mode 100644 internal/debugcli/render_test.go create mode 100644 internal/debugcli/session.go create mode 100644 pkg/runtime/debug.go create mode 100644 pkg/runtime/debug_test.go diff --git a/Makefile b/Makefile index 73307b6..fd5aa9f 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ test: fmt: go fmt ./... && \ - goimports -w -local github.com/MontFerret ./pkg/browser ./cmd ./pkg/config ./ferret ./pkg/logger ./pkg/repl ./pkg/runtime + goimports -w -local github.com/MontFerret ./pkg/browser ./cmd ./internal/debugcli ./pkg/config ./ferret ./pkg/logger ./pkg/repl ./pkg/runtime lint: staticcheck ./... && \ diff --git a/README.md b/README.md index 98ad726..9437b58 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,12 @@ ferret run example.fql > **Note:** `exec` is an alias for `run` — both work interchangeably. +Debug a local source script interactively: + +```bash +ferret debug example.fql +``` + ### Browser Automation For JavaScript-heavy sites, use browser automation: @@ -203,6 +209,7 @@ Available Commands: build Compile FQL scripts into bytecode artifacts check Check FQL scripts for syntax and semantic errors config Manage Ferret configs + debug Debug a FQL script interactively fmt Format FQL scripts inspect Compile and disassemble a FQL script repl Launch interactive FQL shell @@ -242,6 +249,44 @@ ferret exec [script] # alias Compiled artifacts are auto-detected by content for file inputs and piped stdin, so artifacts produced by `ferret build` work even when they do not use a `.fqlc` filename. Artifact execution currently requires the builtin runtime. +### debug + +Launch the local source-level debugger for one FQL script: + +```bash +ferret debug example.fql +ferret debug example.fql --param limit=10 +``` + +The debugger accepts the same runtime, browser, and repeatable `--param name=value` flags as `run`, but currently requires the builtin runtime and a source file. + +``` +Commands: + break Set at next executable location in file + break --exact Set only at the exact executable location + break --next Set at next executable location in file + break --in-function Set at next executable location in function + breakpoints List breakpoints + delete Delete breakpoint + continue Resume execution + step Step into next source location + next Step over current source location + out Step out of current frame + pause Pause at the next source location + where Show stack trace + locals Show local variables + print Evaluate a safe debug expression + quit Stop debugging and exit +``` + +Breakpoint locations may be written as `12`, `12:4`, `file.fql:12`, or `file.fql:12:4`. The default and `--next` modes bind to the next executable location in the file; `--exact` requires an exact executable location, and `--in-function` stays within the current function context. + +`print` uses the conservative debugger evaluator. It supports locals, parameters, literals, member access, scalar arithmetic and comparisons, boolean logic, and conditionals. It does not execute function calls, queries, mutation, asynchronous behavior, or full collection expressions. + +Aliases: `b` = `break`, `c` = `continue`, `s` = `step`, `n` = `next`, `bt` = `where`, `p` = `print`, and `q` = `quit`. + +Phase 1 does not support compiled artifacts, stdin, inline evaluation, remote debugging, DAP, VS Code integration, conditional or hit-count breakpoints, logpoints, variable mutation, tracing, record/replay, or module-specific inspectors. + ### repl Launch the interactive FQL shell. Supports command history, multiline input (toggle with `%`), and all runtime flags. diff --git a/cmd/debug.go b/cmd/debug.go new file mode 100644 index 0000000..3a11897 --- /dev/null +++ b/cmd/debug.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/MontFerret/cli/v2/internal/debugcli" + "github.com/MontFerret/cli/v2/pkg/browser" + "github.com/MontFerret/cli/v2/pkg/config" + clirun "github.com/MontFerret/cli/v2/pkg/run" + cliruntime "github.com/MontFerret/cli/v2/pkg/runtime" +) + +func DebugCommand(store *config.Store) *cobra.Command { + cmd := &cobra.Command{ + Use: "debug ", + Short: "Debug a FQL script interactively", + Long: `Debug a local FQL source script using the interactive Ferret debugger. + +Prompt commands: help, break, delete, breakpoints, continue, step, next, out, +pause, where, locals, print, and quit. + +Debugging currently requires the builtin runtime and does not support compiled +artifacts, stdin, inline evaluation, remote runtimes, or conditional breakpoints.`, + Args: cobra.ExactArgs(1), + PreRun: func(cmd *cobra.Command, _ []string) { + store.BindFlags(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + paramFlags, err := cmd.Flags().GetStringArray(paramFlag) + if err != nil { + return err + } + + params, err := parseParams(paramFlags) + if err != nil { + return err + } + + store := config.From(cmd.Context()) + return executeDebug(cmd, store.GetRuntimeOptions(), store.GetBrowserOptions(), params, args) + }, + } + + addParamFlags(cmd) + addRuntimeFlags(cmd) + + return cmd +} + +func executeDebug(cmd *cobra.Command, rtOpts cliruntime.Options, brOpts browser.Options, params map[string]any, args []string) error { + input, err := clirun.ResolveInput("", args) + if err != nil { + return err + } + + if input != nil && len(input.Artifact) > 0 { + return fmt.Errorf("debugging compiled artifacts is not supported") + } + + if input == nil || input.Source == nil { + return fmt.Errorf("debug requires a source script file") + } + + if err := cliruntime.ValidateOptions(rtOpts); err != nil { + return err + } + + if !cliruntime.IsBuiltinType(rtOpts.Type) { + return cliruntime.ErrDebugRequiresBuiltinRuntime + } + + cleanup, err := browser.EnsureBrowser(cmd.Context(), rtOpts, brOpts) + if err != nil { + return err + } + defer cleanup() + + session, err := cliruntime.NewDebugSession(cmd.Context(), rtOpts, params, input.Source) + if err != nil { + printError(err) + return err + } + + return debugcli.Start(cmd.Context(), session, input.Source) +} diff --git a/cmd/debug_test.go b/cmd/debug_test.go new file mode 100644 index 0000000..c627ac8 --- /dev/null +++ b/cmd/debug_test.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/MontFerret/ferret/v2/pkg/compiler" + "github.com/MontFerret/ferret/v2/pkg/source" + + "github.com/MontFerret/cli/v2/pkg/browser" + "github.com/MontFerret/cli/v2/pkg/build" + "github.com/MontFerret/cli/v2/pkg/config" + cliruntime "github.com/MontFerret/cli/v2/pkg/runtime" +) + +func TestDebugCommandRequiresExactlyOneScript(t *testing.T) { + command := DebugCommand(new(config.Store)) + + if err := command.Args(command, nil); err == nil { + t.Fatal("expected missing argument error") + } + if err := command.Args(command, []string{"one.fql", "two.fql"}); err == nil { + t.Fatal("expected too many arguments error") + } + if err := command.Args(command, []string{"one.fql"}); err != nil { + t.Fatalf("unexpected argument error: %v", err) + } +} + +func TestExecuteDebugRejectsRemoteRuntimeBeforeStarting(t *testing.T) { + path := filepath.Join(t.TempDir(), "query.fql") + writeQuery(t, path, "RETURN 1") + + err := executeDebug( + newTestCommand(), + cliruntime.Options{Type: "https://worker.example"}, + browser.Options{}, + nil, + []string{path}, + ) + if !errors.Is(err, cliruntime.ErrDebugRequiresBuiltinRuntime) { + t.Fatalf("expected builtin runtime error, got %v", err) + } +} + +func TestExecuteDebugRejectsArtifact(t *testing.T) { + dir := t.TempDir() + sourcePath := filepath.Join(dir, "query.fql") + artifactPath := filepath.Join(dir, "query.fqlc") + writeQuery(t, sourcePath, "RETURN 1") + if err := build.WriteArtifact(compiler.New(), source.New(sourcePath, "RETURN 1"), artifactPath); err != nil { + t.Fatal(err) + } + + err := executeDebug( + newTestCommand(), + cliruntime.NewDefaultOptions(), + browser.Options{}, + nil, + []string{artifactPath}, + ) + if err == nil || !strings.Contains(err.Error(), "compiled artifacts") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDebugCommandUsesSharedParamFlags(t *testing.T) { + command := DebugCommand(new(config.Store)) + command.SetContext(config.With(context.Background(), new(config.Store))) + + if err := command.Flags().Set(paramFlag, "limit=2"); err != nil { + t.Fatal(err) + } + values, err := command.Flags().GetStringArray(paramFlag) + if err != nil { + t.Fatal(err) + } + params, err := parseParams(values) + if err != nil { + t.Fatal(err) + } + if params["limit"] != float64(2) { + t.Fatalf("unexpected params: %#v", params) + } +} diff --git a/ferret/main.go b/ferret/main.go index 5dcc42d..878431b 100644 --- a/ferret/main.go +++ b/ferret/main.go @@ -53,6 +53,7 @@ func main() { cmd.VersionCommand(store), cmd.ConfigCommand(store), cmd.RunCommand(store), + cmd.DebugCommand(store), cmd.ReplCommand(store), cmd.FormatCommand(store), cmd.CheckCommand(store), diff --git a/internal/debugcli/command.go b/internal/debugcli/command.go new file mode 100644 index 0000000..51b6ec3 --- /dev/null +++ b/internal/debugcli/command.go @@ -0,0 +1,215 @@ +package debugcli + +import ( + "errors" + "fmt" + "strconv" + "strings" + "unicode" + + "github.com/MontFerret/ferret/v2" +) + +type CommandName string + +const ( + CommandEmpty CommandName = "" + CommandHelp CommandName = "help" + CommandBreak CommandName = "break" + CommandDelete CommandName = "delete" + CommandBreakpoints CommandName = "breakpoints" + CommandContinue CommandName = "continue" + CommandStep CommandName = "step" + CommandNext CommandName = "next" + CommandOut CommandName = "out" + CommandPause CommandName = "pause" + CommandWhere CommandName = "where" + CommandLocals CommandName = "locals" + CommandPrint CommandName = "print" + CommandQuit CommandName = "quit" +) + +type Command struct { + Name CommandName + Argument string + Location ferret.DebugSourceLocation + BreakpointOptions ferret.DebugBreakpointOptions + BreakpointID ferret.DebugBreakpointID +} + +var aliases = map[string]CommandName{ + "b": CommandBreak, + "c": CommandContinue, + "s": CommandStep, + "n": CommandNext, + "bt": CommandWhere, + "p": CommandPrint, + "q": CommandQuit, +} + +func ParseCommand(input string) (Command, error) { + input = strings.TrimSpace(input) + if input == "" { + return Command{}, nil + } + + name := input + argument := "" + if idx := strings.IndexFunc(input, unicode.IsSpace); idx >= 0 { + name = input[:idx] + argument = strings.TrimSpace(input[idx:]) + } + + commandName := CommandName(strings.ToLower(name)) + if alias, ok := aliases[string(commandName)]; ok { + commandName = alias + } + + command := Command{Name: commandName, Argument: argument} + + switch commandName { + case CommandHelp, CommandBreakpoints, CommandContinue, CommandStep, CommandNext, CommandOut, CommandPause, CommandWhere, CommandLocals, CommandQuit: + if argument != "" { + return Command{}, fmt.Errorf("%s does not accept arguments", commandName) + } + case CommandBreak: + location, options, err := parseBreakpoint(argument) + if err != nil { + return Command{}, err + } + command.Location = location + command.BreakpointOptions = options + case CommandDelete: + id, err := parsePositiveNumber(argument, "usage: delete ") + if err != nil { + return Command{}, err + } + command.BreakpointID = ferret.DebugBreakpointID(id) + case CommandPrint: + if argument == "" { + return Command{}, fmt.Errorf("usage: print ") + } + default: + return Command{}, fmt.Errorf("unknown command: %s", name) + } + + return command, nil +} + +const breakpointUsage = "usage: break [--exact|--next|--in-function] [:] or :[:]" + +func parseBreakpoint(argument string) (ferret.DebugSourceLocation, ferret.DebugBreakpointOptions, error) { + var location ferret.DebugSourceLocation + options := ferret.DebugBreakpointOptions{BindingMode: ferret.DebugBreakpointBindNextExecutableInFile} + tokens := strings.Fields(argument) + if len(tokens) == 0 { + return location, options, errors.New(breakpointUsage) + } + + locationText := "" + modeSet := false + for _, token := range tokens { + var mode ferret.DebugBreakpointBindingMode + switch token { + case "--exact": + mode = ferret.DebugBreakpointBindExact + case "--next": + mode = ferret.DebugBreakpointBindNextExecutableInFile + case "--in-function": + mode = ferret.DebugBreakpointBindNextExecutableInFunction + default: + if strings.HasPrefix(token, "--") { + return location, options, fmt.Errorf("unknown break option: %s", token) + } + if locationText != "" { + return location, options, errors.New(breakpointUsage) + } + locationText = token + continue + } + + if modeSet { + return location, options, errors.New("break binding options are mutually exclusive") + } + modeSet = true + options.BindingMode = mode + } + + if locationText == "" { + return location, options, errors.New(breakpointUsage) + } + + location, err := parseBreakpointLocation(locationText) + if err != nil { + return ferret.DebugSourceLocation{}, options, err + } + + return location, options, nil +} + +func parseBreakpointLocation(value string) (ferret.DebugSourceLocation, error) { + var location ferret.DebugSourceLocation + lastColon := strings.LastIndex(value, ":") + if lastColon < 0 { + line, err := parsePositiveNumber(value, breakpointUsage) + if err != nil { + return location, err + } + location.Line = line + return location, nil + } + + last, err := parsePositiveNumber(value[lastColon+1:], breakpointUsage) + if err != nil { + return location, err + } + + prefix := value[:lastColon] + if prefix == "" { + return location, errors.New(breakpointUsage) + } + + previousColon := strings.LastIndex(prefix, ":") + lineText := prefix + if previousColon >= 0 { + lineText = prefix[previousColon+1:] + } + if line, numeric, err := parseOptionalPositiveNumber(lineText); numeric { + if err != nil { + return location, errors.New(breakpointUsage) + } + location.Line = line + location.Column = last + if previousColon >= 0 { + location.File = prefix[:previousColon] + if location.File == "" { + return ferret.DebugSourceLocation{}, errors.New(breakpointUsage) + } + } + return location, nil + } + + location.File = prefix + location.Line = last + return location, nil +} + +func parseOptionalPositiveNumber(value string) (int, bool, error) { + number, err := strconv.Atoi(value) + if err != nil { + return 0, false, nil + } + if number <= 0 { + return 0, true, errors.New("number must be positive") + } + return number, true, nil +} + +func parsePositiveNumber(value, usage string) (int, error) { + number, err := strconv.Atoi(value) + if err != nil || number <= 0 { + return 0, errors.New(usage) + } + + return number, nil +} diff --git a/internal/debugcli/command_test.go b/internal/debugcli/command_test.go new file mode 100644 index 0000000..7da3201 --- /dev/null +++ b/internal/debugcli/command_test.go @@ -0,0 +1,159 @@ +package debugcli + +import ( + "strings" + "testing" + + "github.com/MontFerret/ferret/v2" +) + +func TestParseCommand(t *testing.T) { + tests := []struct { + name string + input string + want Command + errHas string + }{ + {name: "empty", input: " ", want: Command{}}, + { + name: "break line", + input: "break 12", + want: Command{ + Name: CommandBreak, + Argument: "12", + Location: ferret.DebugSourceLocation{Line: 12}, + BreakpointOptions: ferret.DebugBreakpointOptions{BindingMode: ferret.DebugBreakpointBindNextExecutableInFile}, + }, + }, + { + name: "break line and column", + input: "break 12:4", + want: Command{ + Name: CommandBreak, + Argument: "12:4", + Location: ferret.DebugSourceLocation{Line: 12, Column: 4}, + BreakpointOptions: ferret.DebugBreakpointOptions{BindingMode: ferret.DebugBreakpointBindNextExecutableInFile}, + }, + }, + { + name: "break file and line", + input: "break examples/demo.fql:12", + want: Command{ + Name: CommandBreak, + Argument: "examples/demo.fql:12", + Location: ferret.DebugSourceLocation{File: "examples/demo.fql", Line: 12}, + BreakpointOptions: ferret.DebugBreakpointOptions{BindingMode: ferret.DebugBreakpointBindNextExecutableInFile}, + }, + }, + { + name: "break file line and column", + input: "break examples/demo.fql:12:4", + want: Command{ + Name: CommandBreak, + Argument: "examples/demo.fql:12:4", + Location: ferret.DebugSourceLocation{File: "examples/demo.fql", Line: 12, Column: 4}, + BreakpointOptions: ferret.DebugBreakpointOptions{BindingMode: ferret.DebugBreakpointBindNextExecutableInFile}, + }, + }, + { + name: "break Windows drive path", + input: `break C:\work\demo.fql:12:4`, + want: Command{ + Name: CommandBreak, + Argument: `C:\work\demo.fql:12:4`, + Location: ferret.DebugSourceLocation{File: `C:\work\demo.fql`, Line: 12, Column: 4}, + BreakpointOptions: ferret.DebugBreakpointOptions{BindingMode: ferret.DebugBreakpointBindNextExecutableInFile}, + }, + }, + { + name: "break exact before location", + input: "break --exact 12:4", + want: Command{ + Name: CommandBreak, + Argument: "--exact 12:4", + Location: ferret.DebugSourceLocation{Line: 12, Column: 4}, + BreakpointOptions: ferret.DebugBreakpointOptions{BindingMode: ferret.DebugBreakpointBindExact}, + }, + }, + { + name: "break next after location", + input: "break 12 --next", + want: Command{ + Name: CommandBreak, + Argument: "12 --next", + Location: ferret.DebugSourceLocation{Line: 12}, + BreakpointOptions: ferret.DebugBreakpointOptions{BindingMode: ferret.DebugBreakpointBindNextExecutableInFile}, + }, + }, + { + name: "break in function", + input: "break --in-function demo.fql:12", + want: Command{ + Name: CommandBreak, + Argument: "--in-function demo.fql:12", + Location: ferret.DebugSourceLocation{File: "demo.fql", Line: 12}, + BreakpointOptions: ferret.DebugBreakpointOptions{BindingMode: ferret.DebugBreakpointBindNextExecutableInFunction}, + }, + }, + {name: "delete", input: "delete 1", want: Command{Name: CommandDelete, Argument: "1", BreakpointID: ferret.DebugBreakpointID(1)}}, + {name: "print expression", input: "print users[0].name + \" value\"", want: Command{Name: CommandPrint, Argument: `users[0].name + " value"`}}, + {name: "help", input: "help", want: Command{Name: CommandHelp}}, + {name: "breakpoints", input: "breakpoints", want: Command{Name: CommandBreakpoints}}, + {name: "continue", input: "continue", want: Command{Name: CommandContinue}}, + {name: "step", input: "step", want: Command{Name: CommandStep}}, + {name: "next", input: "next", want: Command{Name: CommandNext}}, + {name: "out", input: "out", want: Command{Name: CommandOut}}, + {name: "pause", input: "pause", want: Command{Name: CommandPause}}, + {name: "where", input: "where", want: Command{Name: CommandWhere}}, + {name: "locals", input: "locals", want: Command{Name: CommandLocals}}, + {name: "quit", input: "quit", want: Command{Name: CommandQuit}}, + {name: "tab whitespace", input: "p\tuser.name", want: Command{Name: CommandPrint, Argument: "user.name"}}, + { + name: "break alias", + input: "b 4", + want: Command{ + Name: CommandBreak, + Argument: "4", + Location: ferret.DebugSourceLocation{Line: 4}, + BreakpointOptions: ferret.DebugBreakpointOptions{BindingMode: ferret.DebugBreakpointBindNextExecutableInFile}, + }, + }, + {name: "continue alias", input: "c", want: Command{Name: CommandContinue}}, + {name: "step alias", input: "s", want: Command{Name: CommandStep}}, + {name: "next alias", input: "n", want: Command{Name: CommandNext}}, + {name: "where alias", input: "bt", want: Command{Name: CommandWhere}}, + {name: "quit alias", input: "q", want: Command{Name: CommandQuit}}, + {name: "invalid command", input: "wat", errHas: "unknown command: wat"}, + {name: "missing break line", input: "break", errHas: "usage: break"}, + {name: "invalid break line", input: "break file.fql:nope", errHas: "usage: break"}, + {name: "zero break line", input: "break 0", errHas: "usage: break"}, + {name: "zero break column", input: "break 12:0", errHas: "usage: break"}, + {name: "unknown break option", input: "break --nearest 12", errHas: "unknown break option"}, + {name: "conflicting break options", input: "break --exact 12 --next", errHas: "mutually exclusive"}, + {name: "duplicate break option", input: "break --exact 12 --exact", errHas: "mutually exclusive"}, + {name: "missing location after option", input: "break --exact", errHas: "usage: break"}, + {name: "extra break argument", input: "break 12 extra", errHas: "usage: break"}, + {name: "path with whitespace", input: "break my file.fql:12", errHas: "usage: break"}, + {name: "missing delete id", input: "delete", errHas: "usage: delete"}, + {name: "missing print expression", input: "print", errHas: "usage: print"}, + {name: "unexpected argument", input: "continue now", errHas: "continue does not accept arguments"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := ParseCommand(test.input) + if test.errHas != "" { + if err == nil || !strings.Contains(err.Error(), test.errHas) { + t.Fatalf("expected error containing %q, got %v", test.errHas, err) + } + return + } + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("unexpected command: got %#v, want %#v", got, test.want) + } + }) + } +} diff --git a/internal/debugcli/prompt.go b/internal/debugcli/prompt.go new file mode 100644 index 0000000..02ad65e --- /dev/null +++ b/internal/debugcli/prompt.go @@ -0,0 +1,158 @@ +package debugcli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + + "github.com/chzyer/readline" + + "github.com/MontFerret/ferret/v2" + "github.com/MontFerret/ferret/v2/pkg/runtime" + "github.com/MontFerret/ferret/v2/pkg/source" +) + +func Start(ctx context.Context, session Session, src *source.Source) error { + rl, err := readline.NewEx(&readline.Config{ + Prompt: "(fdb) ", + InterruptPrompt: "^C", + EOFPrompt: "\n", + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + }) + if err != nil { + return errors.Join(err, session.Close()) + } + defer rl.Close() + + return Run(ctx, session, src, rl, rl.Stdout()) +} + +func Run(ctx context.Context, session Session, src *source.Source, input LineReader, out io.Writer) (err error) { + defer func() { + err = errors.Join(err, session.Close()) + }() + + renderer := NewRenderer(out, src) + + fmt.Fprintln(out, "Ferret debugger started.") + event, err := session.Start(ctx) + if err != nil { + return err + } + renderer.Event(event) + fmt.Fprintln(out, `Type "help" for available commands.`) + + for { + line, readErr := input.Readline() + if errors.Is(readErr, readline.ErrInterrupt) { + continue + } + if errors.Is(readErr, io.EOF) { + fmt.Fprintln(out, "Debug session terminated.") + return nil + } + if readErr != nil { + return readErr + } + + command, parseErr := ParseCommand(line) + if parseErr != nil { + fmt.Fprintln(out, parseErr) + continue + } + if command.Name == CommandEmpty { + continue + } + + quit := executeCommand(ctx, session, src.Name(), renderer, command) + if quit { + fmt.Fprintln(out, "Debug session terminated.") + return nil + } + } +} + +func executeCommand(ctx context.Context, session Session, mainFile string, renderer *Renderer, command Command) bool { + switch command.Name { + case CommandHelp: + renderer.Help() + case CommandBreak: + location := command.Location + if location.File == "" { + location.File = mainFile + } + breakpoint, err := session.SetBreakpointAt(location, command.BreakpointOptions) + if err != nil { + renderer.Error("Breakpoint error", err) + } else { + renderer.BreakpointSet(breakpoint) + } + case CommandDelete: + if err := session.DeleteBreakpoint(command.BreakpointID); err != nil { + if errors.Is(err, runtime.ErrNotFound) { + fmt.Fprintf(renderer.out, "Unknown breakpoint: %d\n", command.BreakpointID) + } else { + renderer.Error("Delete breakpoint error", err) + } + } else { + fmt.Fprintf(renderer.out, "Breakpoint %d deleted.\n", command.BreakpointID) + } + case CommandBreakpoints: + renderer.Breakpoints(session.Breakpoints()) + case CommandContinue: + event, err := session.Continue(ctx) + renderResume(event, err, renderer) + case CommandStep: + event, err := session.Step(ctx) + renderResume(event, err, renderer) + case CommandNext: + event, err := session.Next(ctx) + renderResume(event, err, renderer) + case CommandOut: + event, err := session.Out(ctx) + renderResume(event, err, renderer) + case CommandPause: + if err := session.Pause(); err != nil { + renderer.Error("Pause error", err) + } else { + fmt.Fprintln(renderer.out, "Pause requested.") + } + case CommandWhere: + frames, err := session.Frames() + if err != nil { + renderer.Error("Stack error", err) + } else { + renderer.Frames(frames) + } + case CommandLocals: + locals, err := session.Locals() + if err != nil { + renderer.Error("Locals error", err) + } else { + renderer.Locals(locals) + } + case CommandPrint: + value, err := session.Evaluate(ctx, command.Argument) + if err != nil { + renderer.Error("Evaluation error", err) + } else { + renderer.Evaluation(value) + } + case CommandQuit: + return true + } + + return false +} + +func renderResume(event *ferret.DebugEvent, err error, renderer *Renderer) { + if err != nil { + renderer.Error("Debugger error", err) + return + } + renderer.Event(event) +} diff --git a/internal/debugcli/prompt_test.go b/internal/debugcli/prompt_test.go new file mode 100644 index 0000000..7f3e784 --- /dev/null +++ b/internal/debugcli/prompt_test.go @@ -0,0 +1,265 @@ +package debugcli + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + + "github.com/chzyer/readline" + + "github.com/MontFerret/ferret/v2" + ferruntime "github.com/MontFerret/ferret/v2/pkg/runtime" + "github.com/MontFerret/ferret/v2/pkg/source" +) + +func TestRunDispatchesCommandsAndClosesSession(t *testing.T) { + src := source.New("demo.fql", "LET x = 1\nRETURN x") + session := &fakeSession{ + startEvent: debugEvent(ferret.DebugReasonEntry, "demo.fql", 1, source.Span{Start: 0, End: 3}), + continueEvent: debugEvent(ferret.DebugReasonPause, "demo.fql", 2, source.Span{Start: 10, End: 16}), + locals: []ferret.DebugVariable{{Name: "x", Value: ferret.DebugValue{Display: "1"}}}, + frames: []ferret.DebugFrame{{Name: "
", Location: ferret.DebugLocation{File: "demo.fql", Line: 1}}}, + evaluation: ferret.DebugValue{Display: "2"}, + } + input := &fakeLineReader{results: []lineResult{ + {line: ""}, + {err: readline.ErrInterrupt}, + {line: "break --exact 2:1"}, + {line: "breakpoints"}, + {line: "pause"}, + {line: "locals"}, + {line: "print x + 1"}, + {line: "where"}, + {line: "continue"}, + {line: "step"}, + {line: "next"}, + {line: "out"}, + {line: "help"}, + {line: "delete 1"}, + {line: "q"}, + }} + var out bytes.Buffer + + if err := Run(context.Background(), session, src, input, &out); err != nil { + t.Fatal(err) + } + + if !session.started || !session.paused || !session.continued || !session.stepped || !session.nexted || !session.outed || session.closeCalls != 1 { + t.Fatalf("unexpected session calls: %#v", session) + } + if session.breakpointLocation != (ferret.DebugSourceLocation{File: "demo.fql", Line: 2, Column: 1}) { + t.Fatalf("unexpected breakpoint location: %#v", session.breakpointLocation) + } + if session.breakpointOptions.BindingMode != ferret.DebugBreakpointBindExact { + t.Fatalf("unexpected breakpoint options: %#v", session.breakpointOptions) + } + if session.expression != "x + 1" { + t.Fatalf("unexpected expression: %q", session.expression) + } + + got := out.String() + for _, expected := range []string{ + "Ferret debugger started.", + "Paused at demo.fql:1:1", + "Breakpoint 1 set at demo.fql:2:1 (exact).", + "Pause requested.", + "Locals:", + "x = 1", + "\n2\n", + "#0
at demo.fql:1", + "Paused on pause request at demo.fql:2:1", + "Commands:", + "Breakpoint 1 deleted.", + "Debug session terminated.", + } { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in %q", expected, got) + } + } +} + +func TestRunReportsCommandErrorsAndContinues(t *testing.T) { + session := &fakeSession{ + startEvent: debugEvent(ferret.DebugReasonEntry, "demo.fql", 1, source.Span{Start: 0, End: 1}), + continueErr: errors.New("cannot resume while debug session is completed"), + evaluateErr: errors.New("expression is not supported by the safe debugger evaluator"), + } + var out bytes.Buffer + + err := Run(context.Background(), session, source.New("demo.fql", "RETURN 1"), &fakeLineReader{ + results: []lineResult{ + {line: "wat"}, + {line: "delete 99"}, + {line: "print LENGTH([1])"}, + {line: "continue"}, + {line: "q"}, + }, + }, &out) + if err != nil { + t.Fatal(err) + } + + got := out.String() + for _, expected := range []string{ + "unknown command: wat", + "Unknown breakpoint: 99", + "Evaluation error: expression is not supported by the safe debugger evaluator", + "Debugger error: cannot resume while debug session is completed", + } { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in %q", expected, got) + } + } +} + +func TestRunEOFAndCloseError(t *testing.T) { + closeErr := errors.New("close failed") + session := &fakeSession{ + startEvent: debugEvent(ferret.DebugReasonEntry, "demo.fql", 1, source.Span{Start: 0, End: 1}), + closeErr: closeErr, + } + var out bytes.Buffer + + err := Run(context.Background(), session, source.New("demo.fql", "RETURN 1"), &fakeLineReader{ + results: []lineResult{{err: io.EOF}}, + }, &out) + if !errors.Is(err, closeErr) { + t.Fatalf("expected close error, got %v", err) + } + if !strings.Contains(out.String(), "Debug session terminated.") { + t.Fatalf("unexpected output: %q", out.String()) + } + if session.closeCalls != 1 { + t.Fatalf("expected one close call, got %d", session.closeCalls) + } +} + +type lineResult struct { + line string + err error +} + +type fakeLineReader struct { + results []lineResult + index int +} + +func (f *fakeLineReader) Readline() (string, error) { + if f.index >= len(f.results) { + return "", io.EOF + } + result := f.results[f.index] + f.index++ + return result.line, result.err +} + +type fakeSession struct { + startEvent *ferret.DebugEvent + continueEvent *ferret.DebugEvent + locals []ferret.DebugVariable + frames []ferret.DebugFrame + breakpoints []ferret.DebugBreakpoint + evaluation ferret.DebugValue + continueErr error + evaluateErr error + expression string + closeErr error + breakpointLocation ferret.DebugSourceLocation + breakpointOptions ferret.DebugBreakpointOptions + started bool + continued bool + stepped bool + nexted bool + outed bool + paused bool + closeCalls int +} + +func (f *fakeSession) Start(context.Context) (*ferret.DebugEvent, error) { + f.started = true + return f.startEvent, nil +} + +func (f *fakeSession) Continue(context.Context) (*ferret.DebugEvent, error) { + f.continued = true + return f.continueEvent, f.continueErr +} + +func (f *fakeSession) Step(context.Context) (*ferret.DebugEvent, error) { + f.stepped = true + return f.continueEvent, nil +} + +func (f *fakeSession) Next(context.Context) (*ferret.DebugEvent, error) { + f.nexted = true + return f.continueEvent, nil +} + +func (f *fakeSession) Out(context.Context) (*ferret.DebugEvent, error) { + f.outed = true + return f.continueEvent, nil +} + +func (f *fakeSession) Pause() error { + f.paused = true + return nil +} + +func (f *fakeSession) SetBreakpointAt(location ferret.DebugSourceLocation, options ferret.DebugBreakpointOptions) (ferret.DebugBreakpoint, error) { + f.breakpointLocation = location + f.breakpointOptions = options + breakpoint := ferret.DebugBreakpoint{ + ID: ferret.DebugBreakpointID(len(f.breakpoints) + 1), + File: location.File, + RequestedLine: location.Line, + RequestedColumn: location.Column, + Line: location.Line, + Column: location.Column, + BindingMode: options.BindingMode, + Bound: true, + } + f.breakpoints = append(f.breakpoints, breakpoint) + return breakpoint, nil +} + +func (f *fakeSession) DeleteBreakpoint(id ferret.DebugBreakpointID) error { + for i, breakpoint := range f.breakpoints { + if breakpoint.ID == id { + f.breakpoints = append(f.breakpoints[:i], f.breakpoints[i+1:]...) + return nil + } + } + return ferruntime.Errorf(ferruntime.ErrNotFound, "breakpoint %d", id) +} + +func (f *fakeSession) Breakpoints() []ferret.DebugBreakpoint { + return f.breakpoints +} + +func (f *fakeSession) Frames() ([]ferret.DebugFrame, error) { + return f.frames, nil +} + +func (f *fakeSession) Locals() ([]ferret.DebugVariable, error) { + return f.locals, nil +} + +func (f *fakeSession) Evaluate(_ context.Context, expression string) (ferret.DebugValue, error) { + f.expression = expression + return f.evaluation, f.evaluateErr +} + +func (f *fakeSession) Close() error { + f.closeCalls++ + return f.closeErr +} + +func debugEvent(reason ferret.DebugReason, file string, line int, span source.Span) *ferret.DebugEvent { + return &ferret.DebugEvent{ + Reason: reason, + Location: ferret.DebugLocation{File: file, Line: line, Column: 1, Span: span}, + } +} diff --git a/internal/debugcli/render.go b/internal/debugcli/render.go new file mode 100644 index 0000000..08e0008 --- /dev/null +++ b/internal/debugcli/render.go @@ -0,0 +1,241 @@ +package debugcli + +import ( + "fmt" + "io" + "strconv" + "strings" + "text/tabwriter" + + "github.com/MontFerret/ferret/v2" + "github.com/MontFerret/ferret/v2/pkg/diagnostics" + "github.com/MontFerret/ferret/v2/pkg/source" +) + +const helpText = `Commands: + break Set at next executable location in file + break --exact Set only at the exact executable location + break --next Set at next executable location in file + break --in-function Set at next executable location in function + breakpoints List breakpoints + delete Delete breakpoint + continue Resume execution + step Step into next source location + next Step over current source location + out Step out of current frame + pause Pause at the next source location + where Show stack trace + locals Show local variables + print Evaluate a safe expression (no calls, queries, or mutation) + quit Stop debugging and exit + +Locations: 12, 12:4, file.fql:12, file.fql:12:4 + +Aliases: b=break, c=continue, s=step, n=next, bt=where, p=print, q=quit` + +type Renderer struct { + out io.Writer + source *source.Source +} + +func NewRenderer(out io.Writer, src *source.Source) *Renderer { + return &Renderer{out: out, source: src} +} + +func (r *Renderer) Help() { + fmt.Fprintln(r.out, helpText) +} + +func (r *Renderer) Event(event *ferret.DebugEvent) { + if event == nil { + fmt.Fprintln(r.out, "Debugger returned no event.") + return + } + + switch event.Reason { + case ferret.DebugReasonEntry: + fmt.Fprintf(r.out, "Paused at %s\n", formatLocation(event.Location)) + r.snippet(event.Location) + case ferret.DebugReasonBreakpoint: + switch len(event.HitBreakpointIDs) { + case 0: + fmt.Fprintf(r.out, "Paused on breakpoint at %s\n", formatLocation(event.Location)) + case 1: + fmt.Fprintf(r.out, "Paused on breakpoint %d at %s\n", event.HitBreakpointIDs[0], formatLocation(event.Location)) + default: + fmt.Fprintf(r.out, "Paused on breakpoints %s at %s\n", formatBreakpointIDs(event.HitBreakpointIDs), formatLocation(event.Location)) + } + r.snippet(event.Location) + case ferret.DebugReasonStep: + fmt.Fprintf(r.out, "Paused after step at %s\n", formatLocation(event.Location)) + r.snippet(event.Location) + case ferret.DebugReasonPause: + fmt.Fprintf(r.out, "Paused on pause request at %s\n", formatLocation(event.Location)) + r.snippet(event.Location) + case ferret.DebugReasonRuntimeError: + fmt.Fprintln(r.out, "Paused on runtime error.") + r.error(event.Error) + case ferret.DebugReasonCompleted: + fmt.Fprintln(r.out, "Program completed.") + if event.Output != nil { + fmt.Fprintln(r.out, "Result:") + fmt.Fprintln(r.out, string(event.Output.Content)) + } + case ferret.DebugReasonTerminated: + fmt.Fprintln(r.out, "Program terminated.") + r.error(event.Error) + default: + fmt.Fprintf(r.out, "Debugger stopped: %s\n", event.Reason) + } +} + +func (r *Renderer) BreakpointSet(breakpoint ferret.DebugBreakpoint) { + requested := formatSourceLocation(breakpoint.File, breakpoint.RequestedLine, breakpoint.RequestedColumn) + mode := formatBindingMode(breakpoint.BindingMode) + if !breakpoint.Bound { + fmt.Fprintf(r.out, "Breakpoint %d could not be bound at %s (%s).\n", breakpoint.ID, requested, mode) + return + } + + bound := formatSourceLocation(breakpoint.File, breakpoint.Line, breakpoint.Column) + if requested == bound { + fmt.Fprintf(r.out, "Breakpoint %d set at %s (%s).\n", breakpoint.ID, bound, mode) + return + } + fmt.Fprintf(r.out, "Breakpoint %d set at %s (requested %s, %s).\n", breakpoint.ID, bound, requested, mode) +} + +func (r *Renderer) Breakpoints(breakpoints []ferret.DebugBreakpoint) { + if len(breakpoints) == 0 { + fmt.Fprintln(r.out, "No breakpoints.") + return + } + + table := tabwriter.NewWriter(r.out, 0, 4, 2, ' ', 0) + fmt.Fprintln(table, "ID\tRequested\tBound\tMode\tState") + + for _, breakpoint := range breakpoints { + requested := formatSourceLocation(breakpoint.File, breakpoint.RequestedLine, breakpoint.RequestedColumn) + bound := "-" + state := "unbound" + if breakpoint.Bound { + bound = formatSourceLocation(breakpoint.File, breakpoint.Line, breakpoint.Column) + state = "bound" + } + fmt.Fprintf(table, "%d\t%s\t%s\t%s\t%s\n", breakpoint.ID, requested, bound, formatBindingMode(breakpoint.BindingMode), state) + } + + _ = table.Flush() +} + +func (r *Renderer) Frames(frames []ferret.DebugFrame) { + if len(frames) == 0 { + fmt.Fprintln(r.out, "No stack frames available.") + return + } + + for i, frame := range frames { + fmt.Fprintf(r.out, "#%d %s at %s\n", i, frame.Name, formatLocation(frame.Location)) + } +} + +func (r *Renderer) Locals(variables []ferret.DebugVariable) { + locals := make([]ferret.DebugVariable, 0, len(variables)) + params := make([]ferret.DebugVariable, 0, len(variables)) + for _, variable := range variables { + if variable.Param { + params = append(params, variable) + } else { + locals = append(locals, variable) + } + } + + if len(locals) == 0 && len(params) == 0 { + fmt.Fprintln(r.out, "No local variables available.") + return + } + + if len(locals) > 0 { + fmt.Fprintln(r.out, "Locals:") + renderVariables(r.out, locals) + } + if len(params) > 0 { + fmt.Fprintln(r.out, "Params:") + renderVariables(r.out, params) + } +} + +func (r *Renderer) Evaluation(value ferret.DebugValue) { + fmt.Fprintln(r.out, value.Display) +} + +func (r *Renderer) Error(prefix string, err error) { + if err == nil { + return + } + fmt.Fprintf(r.out, "%s: %s\n", prefix, err) +} + +func (r *Renderer) error(err error) { + if err != nil { + fmt.Fprintln(r.out, diagnostics.Format(err)) + } +} + +func (r *Renderer) snippet(location ferret.DebugLocation) { + if r.source == nil || location.File != r.source.Name() || location.Line <= 0 { + return + } + + for _, snippet := range r.source.Snippet(location.Span) { + if snippet.Line != location.Line { + continue + } + + lineNumber := strconv.Itoa(snippet.Line) + fmt.Fprintf(r.out, "%s | %s\n", lineNumber, snippet.Text) + if snippet.Caret != "" { + fmt.Fprintf(r.out, "%s%s\n", strings.Repeat(" ", len(lineNumber)+3), snippet.Caret) + } + return + } +} + +func renderVariables(out io.Writer, variables []ferret.DebugVariable) { + for _, variable := range variables { + fmt.Fprintf(out, " %s = %s\n", variable.Name, variable.Value.Display) + } +} + +func formatBreakpointIDs(ids []ferret.DebugBreakpointID) string { + values := make([]string, 0, len(ids)) + for _, id := range ids { + values = append(values, strconv.Itoa(int(id))) + } + return strings.Join(values, ", ") +} + +func formatBindingMode(mode ferret.DebugBreakpointBindingMode) string { + switch mode { + case ferret.DebugBreakpointBindExact: + return "exact" + case ferret.DebugBreakpointBindNextExecutableInFunction: + return "in-function" + default: + return "next-file" + } +} + +func formatSourceLocation(file string, line, column int) string { + return formatLocation(ferret.DebugLocation{File: file, Line: line, Column: column}) +} + +func formatLocation(location ferret.DebugLocation) string { + if location.Column > 0 { + return fmt.Sprintf("%s:%d:%d", location.File, location.Line, location.Column) + } + if location.Line > 0 { + return fmt.Sprintf("%s:%d", location.File, location.Line) + } + return location.File +} diff --git a/internal/debugcli/render_test.go b/internal/debugcli/render_test.go new file mode 100644 index 0000000..db5c1a2 --- /dev/null +++ b/internal/debugcli/render_test.go @@ -0,0 +1,135 @@ +package debugcli + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/MontFerret/ferret/v2" + "github.com/MontFerret/ferret/v2/pkg/encoding" + "github.com/MontFerret/ferret/v2/pkg/source" +) + +func TestRendererEventPauseAndCompletion(t *testing.T) { + src := source.New("demo.fql", "LET x = 1\nRETURN x") + var out bytes.Buffer + renderer := NewRenderer(&out, src) + + renderer.Event(&ferret.DebugEvent{ + Reason: ferret.DebugReasonBreakpoint, + Location: ferret.DebugLocation{File: "demo.fql", Line: 2, Column: 1, Span: source.Span{Start: 10, End: 16}}, + HitBreakpointIDs: []ferret.DebugBreakpointID{3}, + }) + + got := out.String() + for _, expected := range []string{"Paused on breakpoint 3 at demo.fql:2:1", "2 | RETURN x", "~"} { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in %q", expected, got) + } + } + + out.Reset() + renderer.Event(&ferret.DebugEvent{ + Reason: ferret.DebugReasonCompleted, + Output: &encoding.Output{Content: []byte(`["Ada"]`)}, + }) + if got := out.String(); !strings.Contains(got, "Program completed.\nResult:\n[\"Ada\"]") { + t.Fatalf("unexpected completion output: %q", got) + } +} + +func TestRendererCollectionsAndErrors(t *testing.T) { + var out bytes.Buffer + renderer := NewRenderer(&out, nil) + + renderer.Breakpoints([]ferret.DebugBreakpoint{ + {ID: 1, File: "demo.fql", RequestedLine: 4, RequestedColumn: 3, Line: 7, Column: 5, BindingMode: ferret.DebugBreakpointBindNextExecutableInFile, Bound: true}, + {ID: 2, File: "other.fql", RequestedLine: 9, BindingMode: ferret.DebugBreakpointBindExact}, + }) + renderer.Frames([]ferret.DebugFrame{{Name: "normalize", Location: ferret.DebugLocation{File: "demo.fql", Line: 7, Column: 3}}}) + renderer.Locals([]ferret.DebugVariable{ + {Name: "user", Value: ferret.DebugValue{Display: `{"name": "Ada"}`}}, + {Name: "@limit", Param: true, Value: ferret.DebugValue{Display: "10"}}, + }) + renderer.Error("Evaluation error", errors.New("expected expression")) + renderer.Event(&ferret.DebugEvent{Reason: ferret.DebugReasonRuntimeError, Error: errors.New("division by zero")}) + + got := out.String() + for _, expected := range []string{ + "Requested", "Bound", "Mode", "State", + "demo.fql:4:3", "demo.fql:7:5", "other.fql:9", "next-file", "exact", "bound", "unbound", + "#0 normalize at demo.fql:7:3", + "Locals:", `user = {"name": "Ada"}`, "Params:", "@limit = 10", + "Evaluation error: expected expression", + "Paused on runtime error.", "division by zero", + } { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in %q", expected, got) + } + } +} + +func TestRendererBreakpointHitsAndSetMessages(t *testing.T) { + var out bytes.Buffer + renderer := NewRenderer(&out, nil) + + renderer.Event(&ferret.DebugEvent{ + Reason: ferret.DebugReasonBreakpoint, + Location: ferret.DebugLocation{File: "demo.fql", Line: 12, Column: 4}, + HitBreakpointIDs: []ferret.DebugBreakpointID{3, 7}, + }) + renderer.Event(&ferret.DebugEvent{ + Reason: ferret.DebugReasonPause, + Location: ferret.DebugLocation{File: "demo.fql", Line: 13, Column: 1}, + }) + renderer.Event(&ferret.DebugEvent{ + Reason: ferret.DebugReasonStep, + Location: ferret.DebugLocation{File: "demo.fql", Line: 14, Column: 2}, + }) + renderer.BreakpointSet(ferret.DebugBreakpoint{ + ID: 8, + File: "demo.fql", + RequestedLine: 10, + RequestedColumn: 2, + Line: 12, + Column: 4, + BindingMode: ferret.DebugBreakpointBindNextExecutableInFunction, + Bound: true, + }) + renderer.BreakpointSet(ferret.DebugBreakpoint{ + ID: 9, + File: "demo.fql", + RequestedLine: 20, + BindingMode: ferret.DebugBreakpointBindExact, + }) + + got := out.String() + for _, expected := range []string{ + "Paused on breakpoints 3, 7 at demo.fql:12:4", + "Paused on pause request at demo.fql:13:1", + "Paused after step at demo.fql:14:2", + "Breakpoint 8 set at demo.fql:12:4 (requested demo.fql:10:2, in-function).", + "Breakpoint 9 could not be bound at demo.fql:20 (exact).", + } { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in %q", expected, got) + } + } +} + +func TestRendererEmptyCollections(t *testing.T) { + var out bytes.Buffer + renderer := NewRenderer(&out, nil) + + renderer.Breakpoints(nil) + renderer.Frames(nil) + renderer.Locals(nil) + + got := out.String() + for _, expected := range []string{"No breakpoints.", "No stack frames available.", "No local variables available."} { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in %q", expected, got) + } + } +} diff --git a/internal/debugcli/session.go b/internal/debugcli/session.go new file mode 100644 index 0000000..70fcb2a --- /dev/null +++ b/internal/debugcli/session.go @@ -0,0 +1,27 @@ +package debugcli + +import ( + "context" + + "github.com/MontFerret/ferret/v2" +) + +type Session interface { + Start(context.Context) (*ferret.DebugEvent, error) + Continue(context.Context) (*ferret.DebugEvent, error) + Step(context.Context) (*ferret.DebugEvent, error) + Next(context.Context) (*ferret.DebugEvent, error) + Out(context.Context) (*ferret.DebugEvent, error) + Pause() error + SetBreakpointAt(ferret.DebugSourceLocation, ferret.DebugBreakpointOptions) (ferret.DebugBreakpoint, error) + DeleteBreakpoint(ferret.DebugBreakpointID) error + Breakpoints() []ferret.DebugBreakpoint + Frames() ([]ferret.DebugFrame, error) + Locals() ([]ferret.DebugVariable, error) + Evaluate(context.Context, string) (ferret.DebugValue, error) + Close() error +} + +type LineReader interface { + Readline() (string, error) +} diff --git a/pkg/runtime/builtin.go b/pkg/runtime/builtin.go index 0631ffd..a8a0f3f 100644 --- a/pkg/runtime/builtin.go +++ b/pkg/runtime/builtin.go @@ -25,6 +25,10 @@ type Builtin struct { } func NewBuiltin(opts Options) (Runtime, error) { + return newBuiltin(opts) +} + +func newBuiltin(opts Options) (*Builtin, error) { mods, err := newModules(opts) if err != nil { diff --git a/pkg/runtime/debug.go b/pkg/runtime/debug.go new file mode 100644 index 0000000..1825ee9 --- /dev/null +++ b/pkg/runtime/debug.go @@ -0,0 +1,77 @@ +package runtime + +import ( + "context" + "errors" + "sync" + + "github.com/MontFerret/ferret/v2" + "github.com/MontFerret/ferret/v2/pkg/source" +) + +// DebugSession owns a core debugger session and all runtime resources created +// for it. +type DebugSession struct { + *ferret.DebugSession + + runtime *Builtin + plan *ferret.Plan + closeErr error + closeOnce sync.Once +} + +// NewDebugSession compiles source for debugging and creates a retained-state +// debugger session through the builtin Ferret runtime. +func NewDebugSession(ctx context.Context, opts Options, params map[string]any, src *source.Source) (*DebugSession, error) { + opts = NormalizeOptions(opts) + + if err := ValidateOptions(opts); err != nil { + return nil, err + } + + if !IsBuiltinType(opts.Type) { + return nil, ErrDebugRequiresBuiltinRuntime + } + + rt, err := newBuiltin(opts) + if err != nil { + return nil, err + } + + plan, err := rt.engine.CompileDebug(ctx, src) + if err != nil { + return nil, errors.Join(err, rt.Close()) + } + + session, err := plan.NewDebugSession(ctx, ferret.WithSessionParams(params)) + if err != nil { + return nil, errors.Join(err, plan.Close(), rt.Close()) + } + + return &DebugSession{ + DebugSession: session, + runtime: rt, + plan: plan, + }, nil +} + +// Close releases the debugger session, plan, engine, and logger. +func (s *DebugSession) Close() error { + if s == nil { + return nil + } + + s.closeOnce.Do(func() { + if s.DebugSession != nil { + s.closeErr = errors.Join(s.closeErr, s.DebugSession.Close()) + } + if s.plan != nil { + s.closeErr = errors.Join(s.closeErr, s.plan.Close()) + } + if s.runtime != nil { + s.closeErr = errors.Join(s.closeErr, s.runtime.Close()) + } + }) + + return s.closeErr +} diff --git a/pkg/runtime/debug_test.go b/pkg/runtime/debug_test.go new file mode 100644 index 0000000..5d4124f --- /dev/null +++ b/pkg/runtime/debug_test.go @@ -0,0 +1,162 @@ +package runtime + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/MontFerret/ferret/v2" + "github.com/MontFerret/ferret/v2/pkg/source" +) + +func TestNewDebugSessionIntegration(t *testing.T) { + ctx := context.Background() + session, err := NewDebugSession( + ctx, + NewDefaultOptions(), + map[string]any{"limit": 2}, + source.New("debug.fql", "LET x = 1\nVAR y = @limit\n\ny = y + x\nRETURN y"), + ) + if err != nil { + t.Fatal(err) + } + defer session.Close() + + nextBreakpoint, err := session.SetBreakpointAt( + ferret.DebugSourceLocation{Line: 3}, + ferret.DebugBreakpointOptions{BindingMode: ferret.DebugBreakpointBindNextExecutableInFile}, + ) + if err != nil { + t.Fatal(err) + } + if !nextBreakpoint.Bound || nextBreakpoint.Line != 4 { + t.Fatalf("unexpected next breakpoint: %#v", nextBreakpoint) + } + exactBreakpoint, err := session.SetBreakpointAt( + ferret.DebugSourceLocation{Line: 3}, + ferret.DebugBreakpointOptions{BindingMode: ferret.DebugBreakpointBindExact}, + ) + if err != nil { + t.Fatal(err) + } + if exactBreakpoint.Bound { + t.Fatalf("expected exact breakpoint to remain unbound: %#v", exactBreakpoint) + } + functionBreakpoint, err := session.SetBreakpointAt( + ferret.DebugSourceLocation{Line: 3}, + ferret.DebugBreakpointOptions{BindingMode: ferret.DebugBreakpointBindNextExecutableInFunction}, + ) + if err != nil { + t.Fatal(err) + } + if !functionBreakpoint.Bound || functionBreakpoint.Line != 4 { + t.Fatalf("unexpected in-function breakpoint: %#v", functionBreakpoint) + } + + event, err := session.Start(ctx) + if err != nil { + t.Fatal(err) + } + if event.Reason != ferret.DebugReasonEntry { + t.Fatalf("unexpected start event: %#v", event) + } + + event, err = session.Continue(ctx) + if err != nil { + t.Fatal(err) + } + if event.Reason != ferret.DebugReasonBreakpoint || event.Location.Line != 4 || + len(event.HitBreakpointIDs) != 2 || + event.HitBreakpointIDs[0] != nextBreakpoint.ID || + event.HitBreakpointIDs[1] != functionBreakpoint.ID { + t.Fatalf("unexpected breakpoint event: %#v", event) + } + + value, err := session.Evaluate(ctx, "x + y") + if err != nil { + t.Fatal(err) + } + if value.Display != "3" { + t.Fatalf("unexpected evaluation: %#v", value) + } + if _, err := session.Evaluate(ctx, "LENGTH([1])"); err == nil { + t.Fatal("expected unsafe expression to be rejected") + } + + event, err = session.Continue(ctx) + if err != nil { + t.Fatal(err) + } + if event.Reason != ferret.DebugReasonCompleted || event.Output == nil || string(event.Output.Content) != "3" { + t.Fatalf("unexpected completion event: %#v", event) + } + + if err := session.Close(); err != nil { + t.Fatal(err) + } + if err := session.Close(); err != nil { + t.Fatal(err) + } +} + +func TestNewDebugSessionDocumentSurvivesResume(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte("debug")) + })) + defer server.Close() + + ctx := context.Background() + session, err := NewDebugSession( + ctx, + NewDefaultOptions(), + map[string]any{"url": server.URL}, + source.New("document.fql", "LET doc = DOCUMENT(@url)\nRETURN doc != NONE"), + ) + if err != nil { + t.Fatal(err) + } + defer session.Close() + + event, err := session.Start(ctx) + if err != nil { + t.Fatal(err) + } + if event.Reason != ferret.DebugReasonEntry || event.Location.Line != 1 { + t.Fatalf("unexpected start event: %#v", event) + } + + event, err = session.Next(ctx) + if err != nil { + t.Fatal(err) + } + if event.Reason != ferret.DebugReasonStep || event.Location.Line != 2 { + t.Fatalf("expected next statement after DOCUMENT, got %#v", event) + } + + event, err = session.Continue(ctx) + if err != nil { + t.Fatal(err) + } + if event.Reason != ferret.DebugReasonCompleted || event.Output == nil || string(event.Output.Content) != "true" { + t.Fatalf("unexpected completion event: %#v", event) + } +} + +func TestNewDebugSessionRejectsRemoteRuntime(t *testing.T) { + _, err := NewDebugSession( + context.Background(), + Options{Type: "https://worker.example"}, + nil, + source.NewAnonymous("RETURN 1"), + ) + if !errors.Is(err, ErrDebugRequiresBuiltinRuntime) { + t.Fatalf("expected builtin runtime error, got %v", err) + } + var typed *DebugRequiresBuiltinRuntimeError + if !errors.As(err, &typed) { + t.Fatalf("expected typed builtin runtime error, got %T", err) + } +} diff --git a/pkg/runtime/errors.go b/pkg/runtime/errors.go index bd717cd..657a2ac 100644 --- a/pkg/runtime/errors.go +++ b/pkg/runtime/errors.go @@ -4,3 +4,15 @@ import "errors" // ErrArtifactRequiresBuiltinRuntime indicates compiled artifacts can only run on the builtin runtime. var ErrArtifactRequiresBuiltinRuntime = errors.New("compiled artifacts require the builtin runtime") + +// DebugRequiresBuiltinRuntimeError reports an attempt to debug through a +// runtime that cannot create local debugger sessions. +type DebugRequiresBuiltinRuntimeError struct{} + +func (*DebugRequiresBuiltinRuntimeError) Error() string { + return "debugging requires the builtin runtime" +} + +// ErrDebugRequiresBuiltinRuntime indicates source debugging is only available +// through the builtin runtime. +var ErrDebugRequiresBuiltinRuntime = &DebugRequiresBuiltinRuntimeError{} From 19928e950235463c81a10a4bfc6c53faac091f5b Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Sun, 14 Jun 2026 15:00:17 -0400 Subject: [PATCH 2/6] Upgrade Ferret module version to v2.0.0-alpha.23 in go.mod and go.sum, and update dependencies --- go.mod | 9 ++++++--- go.sum | 26 +++++++++++++++----------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/go.mod b/go.mod index efe7555..b6789e3 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/MontFerret/contrib/modules/web/sitemap v1.0.0-rc.3 github.com/MontFerret/contrib/modules/xml v1.0.0-rc.3 github.com/MontFerret/contrib/modules/yaml v1.0.0-rc.3 - github.com/MontFerret/ferret/v2 v2.0.0-alpha.22 + github.com/MontFerret/ferret/v2 v2.0.0-alpha.23 github.com/chzyer/readline v1.5.1 github.com/go-waitfor/waitfor v1.1.0 github.com/go-waitfor/waitfor-http v1.1.0 @@ -43,7 +43,7 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -52,6 +52,7 @@ require ( github.com/mattn/go-isatty v0.0.22 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sethgrid/pester v1.2.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect @@ -62,10 +63,12 @@ require ( github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/wI2L/jettison v0.7.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 854ad6d..3d2a423 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,8 @@ github.com/MontFerret/contrib/modules/yaml v1.0.0-rc.3 h1:sDHD9uddxr7ZboF7qKgHRg github.com/MontFerret/contrib/modules/yaml v1.0.0-rc.3/go.mod h1:oxsS3gWk2tYqIGi+PCxuqTPVZRAxHwpdnrX7XJOcjbw= github.com/MontFerret/cssx v0.2.0 h1:De0C6Irbg+qgFPXgWmPpVnwD4RRYUBQSbIYFTUVCNWU= github.com/MontFerret/cssx v0.2.0/go.mod h1:fmGtRUNVaeJYpiPSDlNIbbYzb3+K8NxmNmJOYqlHATU= -github.com/MontFerret/ferret/v2 v2.0.0-alpha.22 h1:3N68LO1Fw5IEpyc479ibaF6AVlDcc+a5IeookVHQNxA= -github.com/MontFerret/ferret/v2 v2.0.0-alpha.22/go.mod h1:xnhO/65Sa17LSRC8GLIO51JZ4Wm08JjEtmowGVut4z0= +github.com/MontFerret/ferret/v2 v2.0.0-alpha.23 h1:K4b1sfWkFsNlz4+cLy9VV4FZdDZKplTIF8jIbmN+gAU= +github.com/MontFerret/ferret/v2 v2.0.0-alpha.23/go.mod h1:g3crJNLHr8i1g+GO9YX8ohYqbey3atUZmtpyJM7wcgA= github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= @@ -70,8 +70,9 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -89,8 +90,11 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mafredri/cdp v0.35.0 h1:fKQ6LbcH3WsxVrWbi/DSgLunJTqmF5o/7w8iFDDj71c= @@ -116,8 +120,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -173,8 +177,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= -golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -244,11 +248,11 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From c3854752d7d249a80b9c6391cb7be2e3a646f68b Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Sun, 14 Jun 2026 15:07:38 -0400 Subject: [PATCH 3/6] Refactor debug package: move `debugcli` to `debugger` and update references --- cmd/debug.go | 4 +-- internal/debugcli/session.go | 27 ----------------- .../debugcli => pkg/debugger}/command.go | 22 +++++++++++++- .../debugcli => pkg/debugger}/command_test.go | 2 +- {internal/debugcli => pkg/debugger}/prompt.go | 17 ++++++++++- .../debugcli => pkg/debugger}/prompt_test.go | 2 +- {internal/debugcli => pkg/debugger}/render.go | 16 +++++++++- .../debugcli => pkg/debugger}/render_test.go | 2 +- pkg/debugger/session.go | 29 +++++++++++++++++++ 9 files changed, 86 insertions(+), 35 deletions(-) delete mode 100644 internal/debugcli/session.go rename {internal/debugcli => pkg/debugger}/command.go (99%) rename {internal/debugcli => pkg/debugger}/command_test.go (99%) rename {internal/debugcli => pkg/debugger}/prompt.go (99%) rename {internal/debugcli => pkg/debugger}/prompt_test.go (99%) rename {internal/debugcli => pkg/debugger}/render.go (99%) rename {internal/debugcli => pkg/debugger}/render_test.go (99%) create mode 100644 pkg/debugger/session.go diff --git a/cmd/debug.go b/cmd/debug.go index 3a11897..c380fa3 100644 --- a/cmd/debug.go +++ b/cmd/debug.go @@ -5,9 +5,9 @@ import ( "github.com/spf13/cobra" - "github.com/MontFerret/cli/v2/internal/debugcli" "github.com/MontFerret/cli/v2/pkg/browser" "github.com/MontFerret/cli/v2/pkg/config" + "github.com/MontFerret/cli/v2/pkg/debugger" clirun "github.com/MontFerret/cli/v2/pkg/run" cliruntime "github.com/MontFerret/cli/v2/pkg/runtime" ) @@ -83,5 +83,5 @@ func executeDebug(cmd *cobra.Command, rtOpts cliruntime.Options, brOpts browser. return err } - return debugcli.Start(cmd.Context(), session, input.Source) + return debugger.Start(cmd.Context(), session, input.Source) } diff --git a/internal/debugcli/session.go b/internal/debugcli/session.go deleted file mode 100644 index 70fcb2a..0000000 --- a/internal/debugcli/session.go +++ /dev/null @@ -1,27 +0,0 @@ -package debugcli - -import ( - "context" - - "github.com/MontFerret/ferret/v2" -) - -type Session interface { - Start(context.Context) (*ferret.DebugEvent, error) - Continue(context.Context) (*ferret.DebugEvent, error) - Step(context.Context) (*ferret.DebugEvent, error) - Next(context.Context) (*ferret.DebugEvent, error) - Out(context.Context) (*ferret.DebugEvent, error) - Pause() error - SetBreakpointAt(ferret.DebugSourceLocation, ferret.DebugBreakpointOptions) (ferret.DebugBreakpoint, error) - DeleteBreakpoint(ferret.DebugBreakpointID) error - Breakpoints() []ferret.DebugBreakpoint - Frames() ([]ferret.DebugFrame, error) - Locals() ([]ferret.DebugVariable, error) - Evaluate(context.Context, string) (ferret.DebugValue, error) - Close() error -} - -type LineReader interface { - Readline() (string, error) -} diff --git a/internal/debugcli/command.go b/pkg/debugger/command.go similarity index 99% rename from internal/debugcli/command.go rename to pkg/debugger/command.go index 51b6ec3..11cd9f5 100644 --- a/internal/debugcli/command.go +++ b/pkg/debugger/command.go @@ -1,4 +1,4 @@ -package debugcli +package debugger import ( "errors" @@ -77,6 +77,7 @@ func ParseCommand(input string) (Command, error) { if err != nil { return Command{}, err } + command.Location = location command.BreakpointOptions = options case CommandDelete: @@ -84,6 +85,7 @@ func ParseCommand(input string) (Command, error) { if err != nil { return Command{}, err } + command.BreakpointID = ferret.DebugBreakpointID(id) case CommandPrint: if argument == "" { @@ -102,12 +104,14 @@ func parseBreakpoint(argument string) (ferret.DebugSourceLocation, ferret.DebugB var location ferret.DebugSourceLocation options := ferret.DebugBreakpointOptions{BindingMode: ferret.DebugBreakpointBindNextExecutableInFile} tokens := strings.Fields(argument) + if len(tokens) == 0 { return location, options, errors.New(breakpointUsage) } locationText := "" modeSet := false + for _, token := range tokens { var mode ferret.DebugBreakpointBindingMode switch token { @@ -121,16 +125,20 @@ func parseBreakpoint(argument string) (ferret.DebugSourceLocation, ferret.DebugB if strings.HasPrefix(token, "--") { return location, options, fmt.Errorf("unknown break option: %s", token) } + if locationText != "" { return location, options, errors.New(breakpointUsage) } + locationText = token + continue } if modeSet { return location, options, errors.New("break binding options are mutually exclusive") } + modeSet = true options.BindingMode = mode } @@ -150,12 +158,15 @@ func parseBreakpoint(argument string) (ferret.DebugSourceLocation, ferret.DebugB func parseBreakpointLocation(value string) (ferret.DebugSourceLocation, error) { var location ferret.DebugSourceLocation lastColon := strings.LastIndex(value, ":") + if lastColon < 0 { line, err := parsePositiveNumber(value, breakpointUsage) if err != nil { return location, err } + location.Line = line + return location, nil } @@ -171,26 +182,33 @@ func parseBreakpointLocation(value string) (ferret.DebugSourceLocation, error) { previousColon := strings.LastIndex(prefix, ":") lineText := prefix + if previousColon >= 0 { lineText = prefix[previousColon+1:] } + if line, numeric, err := parseOptionalPositiveNumber(lineText); numeric { if err != nil { return location, errors.New(breakpointUsage) } + location.Line = line location.Column = last + if previousColon >= 0 { location.File = prefix[:previousColon] + if location.File == "" { return ferret.DebugSourceLocation{}, errors.New(breakpointUsage) } } + return location, nil } location.File = prefix location.Line = last + return location, nil } @@ -199,9 +217,11 @@ func parseOptionalPositiveNumber(value string) (int, bool, error) { if err != nil { return 0, false, nil } + if number <= 0 { return 0, true, errors.New("number must be positive") } + return number, true, nil } diff --git a/internal/debugcli/command_test.go b/pkg/debugger/command_test.go similarity index 99% rename from internal/debugcli/command_test.go rename to pkg/debugger/command_test.go index 7da3201..fa14f2e 100644 --- a/internal/debugcli/command_test.go +++ b/pkg/debugger/command_test.go @@ -1,4 +1,4 @@ -package debugcli +package debugger import ( "strings" diff --git a/internal/debugcli/prompt.go b/pkg/debugger/prompt.go similarity index 99% rename from internal/debugcli/prompt.go rename to pkg/debugger/prompt.go index 02ad65e..4c52548 100644 --- a/internal/debugcli/prompt.go +++ b/pkg/debugger/prompt.go @@ -1,4 +1,4 @@ -package debugcli +package debugger import ( "context" @@ -23,6 +23,7 @@ func Start(ctx context.Context, session Session, src *source.Source) error { Stdout: os.Stdout, Stderr: os.Stderr, }) + if err != nil { return errors.Join(err, session.Close()) } @@ -43,32 +44,39 @@ func Run(ctx context.Context, session Session, src *source.Source, input LineRea if err != nil { return err } + renderer.Event(event) fmt.Fprintln(out, `Type "help" for available commands.`) for { line, readErr := input.Readline() + if errors.Is(readErr, readline.ErrInterrupt) { continue } + if errors.Is(readErr, io.EOF) { fmt.Fprintln(out, "Debug session terminated.") return nil } + if readErr != nil { return readErr } command, parseErr := ParseCommand(line) + if parseErr != nil { fmt.Fprintln(out, parseErr) continue } + if command.Name == CommandEmpty { continue } quit := executeCommand(ctx, session, src.Name(), renderer, command) + if quit { fmt.Fprintln(out, "Debug session terminated.") return nil @@ -82,10 +90,13 @@ func executeCommand(ctx context.Context, session Session, mainFile string, rende renderer.Help() case CommandBreak: location := command.Location + if location.File == "" { location.File = mainFile } + breakpoint, err := session.SetBreakpointAt(location, command.BreakpointOptions) + if err != nil { renderer.Error("Breakpoint error", err) } else { @@ -123,6 +134,7 @@ func executeCommand(ctx context.Context, session Session, mainFile string, rende } case CommandWhere: frames, err := session.Frames() + if err != nil { renderer.Error("Stack error", err) } else { @@ -130,6 +142,7 @@ func executeCommand(ctx context.Context, session Session, mainFile string, rende } case CommandLocals: locals, err := session.Locals() + if err != nil { renderer.Error("Locals error", err) } else { @@ -137,6 +150,7 @@ func executeCommand(ctx context.Context, session Session, mainFile string, rende } case CommandPrint: value, err := session.Evaluate(ctx, command.Argument) + if err != nil { renderer.Error("Evaluation error", err) } else { @@ -154,5 +168,6 @@ func renderResume(event *ferret.DebugEvent, err error, renderer *Renderer) { renderer.Error("Debugger error", err) return } + renderer.Event(event) } diff --git a/internal/debugcli/prompt_test.go b/pkg/debugger/prompt_test.go similarity index 99% rename from internal/debugcli/prompt_test.go rename to pkg/debugger/prompt_test.go index 7f3e784..a0efc5e 100644 --- a/internal/debugcli/prompt_test.go +++ b/pkg/debugger/prompt_test.go @@ -1,4 +1,4 @@ -package debugcli +package debugger import ( "bytes" diff --git a/internal/debugcli/render.go b/pkg/debugger/render.go similarity index 99% rename from internal/debugcli/render.go rename to pkg/debugger/render.go index 08e0008..1aaabce 100644 --- a/internal/debugcli/render.go +++ b/pkg/debugger/render.go @@ -1,4 +1,4 @@ -package debugcli +package debugger import ( "fmt" @@ -65,6 +65,7 @@ func (r *Renderer) Event(event *ferret.DebugEvent) { default: fmt.Fprintf(r.out, "Paused on breakpoints %s at %s\n", formatBreakpointIDs(event.HitBreakpointIDs), formatLocation(event.Location)) } + r.snippet(event.Location) case ferret.DebugReasonStep: fmt.Fprintf(r.out, "Paused after step at %s\n", formatLocation(event.Location)) @@ -92,6 +93,7 @@ func (r *Renderer) Event(event *ferret.DebugEvent) { func (r *Renderer) BreakpointSet(breakpoint ferret.DebugBreakpoint) { requested := formatSourceLocation(breakpoint.File, breakpoint.RequestedLine, breakpoint.RequestedColumn) mode := formatBindingMode(breakpoint.BindingMode) + if !breakpoint.Bound { fmt.Fprintf(r.out, "Breakpoint %d could not be bound at %s (%s).\n", breakpoint.ID, requested, mode) return @@ -102,6 +104,7 @@ func (r *Renderer) BreakpointSet(breakpoint ferret.DebugBreakpoint) { fmt.Fprintf(r.out, "Breakpoint %d set at %s (%s).\n", breakpoint.ID, bound, mode) return } + fmt.Fprintf(r.out, "Breakpoint %d set at %s (requested %s, %s).\n", breakpoint.ID, bound, requested, mode) } @@ -118,10 +121,12 @@ func (r *Renderer) Breakpoints(breakpoints []ferret.DebugBreakpoint) { requested := formatSourceLocation(breakpoint.File, breakpoint.RequestedLine, breakpoint.RequestedColumn) bound := "-" state := "unbound" + if breakpoint.Bound { bound = formatSourceLocation(breakpoint.File, breakpoint.Line, breakpoint.Column) state = "bound" } + fmt.Fprintf(table, "%d\t%s\t%s\t%s\t%s\n", breakpoint.ID, requested, bound, formatBindingMode(breakpoint.BindingMode), state) } @@ -142,6 +147,7 @@ func (r *Renderer) Frames(frames []ferret.DebugFrame) { func (r *Renderer) Locals(variables []ferret.DebugVariable) { locals := make([]ferret.DebugVariable, 0, len(variables)) params := make([]ferret.DebugVariable, 0, len(variables)) + for _, variable := range variables { if variable.Param { params = append(params, variable) @@ -159,6 +165,7 @@ func (r *Renderer) Locals(variables []ferret.DebugVariable) { fmt.Fprintln(r.out, "Locals:") renderVariables(r.out, locals) } + if len(params) > 0 { fmt.Fprintln(r.out, "Params:") renderVariables(r.out, params) @@ -173,6 +180,7 @@ func (r *Renderer) Error(prefix string, err error) { if err == nil { return } + fmt.Fprintf(r.out, "%s: %s\n", prefix, err) } @@ -194,9 +202,11 @@ func (r *Renderer) snippet(location ferret.DebugLocation) { lineNumber := strconv.Itoa(snippet.Line) fmt.Fprintf(r.out, "%s | %s\n", lineNumber, snippet.Text) + if snippet.Caret != "" { fmt.Fprintf(r.out, "%s%s\n", strings.Repeat(" ", len(lineNumber)+3), snippet.Caret) } + return } } @@ -209,9 +219,11 @@ func renderVariables(out io.Writer, variables []ferret.DebugVariable) { func formatBreakpointIDs(ids []ferret.DebugBreakpointID) string { values := make([]string, 0, len(ids)) + for _, id := range ids { values = append(values, strconv.Itoa(int(id))) } + return strings.Join(values, ", ") } @@ -234,8 +246,10 @@ func formatLocation(location ferret.DebugLocation) string { if location.Column > 0 { return fmt.Sprintf("%s:%d:%d", location.File, location.Line, location.Column) } + if location.Line > 0 { return fmt.Sprintf("%s:%d", location.File, location.Line) } + return location.File } diff --git a/internal/debugcli/render_test.go b/pkg/debugger/render_test.go similarity index 99% rename from internal/debugcli/render_test.go rename to pkg/debugger/render_test.go index db5c1a2..09ce776 100644 --- a/internal/debugcli/render_test.go +++ b/pkg/debugger/render_test.go @@ -1,4 +1,4 @@ -package debugcli +package debugger import ( "bytes" diff --git a/pkg/debugger/session.go b/pkg/debugger/session.go new file mode 100644 index 0000000..05d3f44 --- /dev/null +++ b/pkg/debugger/session.go @@ -0,0 +1,29 @@ +package debugger + +import ( + "context" + + "github.com/MontFerret/ferret/v2" +) + +type ( + Session interface { + Start(context.Context) (*ferret.DebugEvent, error) + Continue(context.Context) (*ferret.DebugEvent, error) + Step(context.Context) (*ferret.DebugEvent, error) + Next(context.Context) (*ferret.DebugEvent, error) + Out(context.Context) (*ferret.DebugEvent, error) + Pause() error + SetBreakpointAt(ferret.DebugSourceLocation, ferret.DebugBreakpointOptions) (ferret.DebugBreakpoint, error) + DeleteBreakpoint(ferret.DebugBreakpointID) error + Breakpoints() []ferret.DebugBreakpoint + Frames() ([]ferret.DebugFrame, error) + Locals() ([]ferret.DebugVariable, error) + Evaluate(context.Context, string) (ferret.DebugValue, error) + Close() error + } + + LineReader interface { + Readline() (string, error) + } +) From 3260e5a6c538f68392f2ae1fff8dc40a82d6cc1d Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Sun, 14 Jun 2026 15:16:24 -0400 Subject: [PATCH 4/6] Add AGENTS.md as the canonical operating guide for CLI development in the Ferret repository --- AGENTS.md | 556 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 556 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8bfc798 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,556 @@ +# AGENTS.md + +This file is the canonical operating guide for coding agents working in the Ferret CLI repository. It is written for the Ferret v2 CLI only. If repository documentation conflicts with this file, prefer `Makefile`, `go.mod`, `.github/workflows/*`, and `.goreleaser.yml` for commands, toolchain, CI, and release behavior. + +## Repo snapshot + +- Module path: `github.com/MontFerret/cli/v2` +- Go version in `go.mod`: `1.26.1` +- Binary entrypoint: `ferret/main.go` +- Built binary name: `ferret` +- Core Ferret dependency: `github.com/MontFerret/ferret/v2` +- This repository is the CLI/tooling layer for Ferret v2. Do not assume ownership of core language, compiler, VM, bytecode, parser, or runtime semantics unless the behavior is explicitly implemented in this repository. + +## Architectural mental model + +The CLI repository wraps Ferret v2 core capabilities into user-facing commands. + +Primary flow: + +```text +user command -> Cobra command -> CLI option parsing -> source/runtime/browser/config helpers -> Ferret core API -> output/debug/rendering +``` + +Agents should reason about changes by ownership boundary: + +- Command shape, flags, aliases, help text, and user-facing command behavior usually begin in `cmd`. +- Script/source resolution usually belongs in `pkg/source` or the command package that owns the input mode. +- Runtime selection, builtin-vs-remote behavior, parameter passing, and execution setup usually belong in `pkg/runtime` or `pkg/run`. +- Browser process setup, lifecycle, and browser flags belong in `pkg/browser`. +- Interactive debugger REPL behavior belongs in `pkg/debugger`; command-level debug wiring belongs in `cmd/debug.go`; Ferret-core debug session creation belongs in `pkg/runtime/debug.go`. +- Interactive FQL REPL behavior belongs in `pkg/repl`; command wiring belongs in `cmd/repl.go`. +- Build artifact planning and path behavior belongs in `pkg/build`; command wiring belongs in `cmd/build.go`. +- Configuration persistence and config flag binding belong in `pkg/config`. +- Logging behavior belongs in `pkg/logger`. +- Self-update behavior belongs in `pkg/selfupdate` and `cmd/update.go`. +- Release packaging belongs in `.goreleaser.yml`, `scripts/release.sh`, and the Makefile release target. + +The CLI should call into Ferret core rather than reimplementing language semantics locally. + +## Canonical invariants + +- The CLI does not own FQL language semantics. +- The CLI does not own Ferret compiler, VM, bytecode, parser, optimizer, or core runtime value semantics. +- User-facing command behavior must remain predictable, scriptable, and stable unless a task explicitly changes it. +- CLI errors should be actionable and should preserve the best available context: command, input path, runtime type, flag value, or source location. +- Runtime execution must preserve Ferret core behavior instead of compensating for core behavior in CLI-only code. +- Browser resources must be cleaned up deterministically where this repository starts or owns them. +- Remote runtime behavior and builtin runtime behavior must remain clearly separated. +- Debugger-disabled execution paths must not pay for debugger-specific setup. +- Do not assume behavior from the v1 CLI branch unless it is reflected in this repository. + +## Package map + +Agents should begin with the package whose responsibility owns the requested behavior. Do not infer ownership from file names alone when a package in this map already describes the intended boundary. + +### Command layer + +* `cmd` + * Owns Cobra command definitions, command hierarchy, CLI help, flag registration, argument validation, and high-level command orchestration. + * Keep command files thin. They should parse CLI intent, validate inputs, assemble options, call the owning package, and render command-level errors. + * Do not bury business logic in Cobra callbacks when it naturally belongs in `pkg/*`. + * Do not change command names, aliases, flags, or output formats casually; these are user-facing API. + +* `ferret` + * Owns the binary entrypoint and version injection target. + * Keep this package minimal. It should wire and execute the root command, not implement command behavior. + +### Execution and source handling + +* `pkg/run` + * Owns shared run/execute flows and execution input resolution used by commands such as `run`. + * Prefer this package when changing how CLI execution is prepared, resolved, or dispatched after command-level options are known. + * Do not place browser-specific process management here; delegate to `pkg/browser`. + +* `pkg/runtime` + * Owns CLI runtime selection and runtime adapters for builtin and remote execution. + * Owns conversion from CLI runtime options to Ferret core execution/debug sessions. + * Keep builtin and remote behavior explicitly separated. + * Do not implement core Ferret semantics here; call Ferret core APIs. + * Debug-session creation for the CLI belongs here, but interactive debugger behavior does not. + +* `pkg/source` + * Owns CLI source resolution from files, inline input, stdin-like flows, or command-specific source modes. + * Use this package when behavior depends on how the CLI identifies or loads user-provided source. + * Preserve source identity so Ferret core diagnostics and debugger output can point back to useful paths. + +### Browser and environment support + +* `pkg/browser` + * Owns browser process discovery, startup, wait/readiness behavior, lifecycle cleanup, and browser-specific flags/options. + * Platform-specific files should keep OS-specific behavior isolated. + * Browser startup should be lazy or conditional when possible; avoid starting browsers for commands that do not need one. + * Ensure cleanup paths work on success, failure, cancellation, and early validation errors where this package owns resources. + +* `pkg/config` + * Owns config store behavior, config context, config initialization, config flag binding, and persistent CLI configuration. + * Preserve key names and config semantics unless the task explicitly changes them. + * Avoid mixing command-specific validation into generic config storage. + +* `pkg/logger` + * Owns CLI logging options and logger construction. + * Logging should remain observational. + * Do not make command semantics, runtime behavior, or control flow depend on log output. + +### Developer tooling commands + +* `pkg/debugger` + * Owns the interactive debugger REPL, debugger command parsing, command dispatch, rendering, and the CLI-facing debugger session interface. + * It should consume Ferret core debug sessions through a small interface seam. + * It should not own Ferret VM semantics, breakpoint binding semantics, runtime value semantics, or protocol-level DAP behavior. + * Keep REPL lifecycle behavior explicit: not started, paused, completed, terminated, closed, or equivalent local states. + * User-facing debugger output should be stable, readable, and friendly for terminal use. + +* `pkg/repl` + * Owns interactive FQL shell behavior. + * Keep command wiring in `cmd/repl.go`; keep interactive loop behavior here. + * Preserve cancellation, exit, and user input behavior carefully because this package is interactive. + +* `pkg/build` + * Owns build artifact planning, artifact path handling, and compiled output decisions for CLI build flows. + * Do not implement compiler semantics here. Use Ferret core APIs for compilation. + * Keep path behavior explicit and well-tested. + +### Maintenance and release support + +* `pkg/selfupdate` + * Owns CLI self-update behavior and update checks. + * Network behavior must be explicit and error messages must be actionable. + * Do not silently replace binaries or mutate installation state without clear command intent. + +* `scripts` + * Owns shell helpers used by Makefile or release flows. + * Keep scripts portable where practical and avoid duplicating logic that already exists in Makefile or Go code. + +* `.goreleaser.yml` + * Owns release packaging behavior. + * Treat changes here as release-sensitive. + +## Where to start by task + +- Add or change a command: + - inspect the relevant file in `cmd` + - check existing command/flag patterns + - place reusable behavior in the appropriate `pkg/*` package + - add command-level tests in `cmd` + +- Add or change a flag: + - inspect `cmd/flags.go` and the owning command file + - inspect the owning options type in `pkg/*` + - update help text and tests + - preserve backward compatibility unless explicitly changing the flag + +- Change run behavior: + - inspect `cmd/run.go` + - inspect `pkg/run` + - inspect `pkg/runtime` + - inspect `pkg/browser` if browser startup or lifecycle is affected + +- Change debug behavior: + - inspect `cmd/debug.go` for command wiring + - inspect `pkg/runtime/debug.go` for Ferret core debug session setup + - inspect `pkg/debugger` for REPL commands, lifecycle, parsing, and rendering + - add focused tests in `cmd`, `pkg/runtime`, and/or `pkg/debugger` based on the changed layer + +- Change build or inspect behavior: + - inspect `cmd/build.go` or `cmd/inspect.go` + - inspect `pkg/build` for artifact planning/path behavior + - rely on Ferret core for compilation/disassembly semantics + +- Change browser behavior: + - inspect `cmd/browser.go` + - inspect `pkg/browser` + - test platform-neutral behavior where possible + - isolate platform-specific changes in OS-specific files + +- Change config behavior: + - inspect `cmd/config.go` + - inspect `pkg/config` + - preserve existing key names and storage behavior unless explicitly changed + +- Change formatting/checking behavior: + - inspect `cmd/format.go` or `cmd/check.go` + - call Ferret core formatter/checker APIs rather than reimplementing language logic + - add tests for CLI behavior and error/output shape + +- Change update/release behavior: + - inspect `cmd/update.go`, `pkg/selfupdate`, `scripts/release.sh`, `.goreleaser.yml`, and `Makefile` + - treat this as release-sensitive and validate carefully + +## Stability guide + +Treat these as relatively stable unless the task explicitly targets them: + +- command names and primary aliases +- user-facing flags and config keys +- command exit behavior +- output formats that users may script around +- runtime selection model: builtin vs remote +- browser lifecycle expectations +- release binary name and version injection path + +Treat these as implementation-sensitive and verify current code before proposing changes: + +- debugger REPL lifecycle and command dispatch +- browser startup/wait/cleanup behavior +- remote runtime request/response behavior +- config file loading and persistence +- path resolution for source files and build artifacts +- self-update and release scripts + +Do not treat historical discussion, stale README text, v1 behavior, or old branches as authoritative. + +## Public command and compatibility rules + +- Treat commands, flags, aliases, config keys, exit status behavior, and machine-readable output as CLI API. +- Do not remove or rename public flags without explicit instruction. +- Prefer adding aliases over replacing existing names. +- Preserve existing stdout/stderr intent. User-facing results should usually go to stdout; diagnostics and errors should usually go to stderr. +- Keep command help clear and short enough to be useful in a terminal. +- When adding new output intended for humans, avoid making existing scripted output harder to parse. +- Any intentional backward-incompatible CLI behavior change must be called out explicitly in the final summary. + +## Ferret core boundary rules + +- Do not reimplement FQL parsing, formatting, compilation, bytecode handling, VM behavior, runtime value semantics, or debugger breakpoint binding in the CLI. +- If a behavior belongs in Ferret core, change Ferret core separately or adapt to the existing core API. +- The CLI may translate core errors into clearer command-level messages, but it should not obscure specific diagnostics. +- Keep CLI adapters thin around Ferret core APIs. +- When a core Ferret version bump changes behavior, update CLI tests and docs to reflect the new contract. + +## Debugger CLI rules + +- `cmd/debug.go` owns CLI argument/flag validation and high-level debug startup. +- `pkg/runtime/debug.go` owns creation of the Ferret core debug session from CLI runtime options. +- `pkg/debugger` owns interactive debugger UX: command parsing, aliases, dispatch, prompt lifecycle, and terminal rendering. +- The debugger REPL should depend on a small session interface, not directly on command or runtime packages. +- Do not add daemon, DAP, or remote debug assumptions to the local CLI debugger unless the task explicitly requests it. +- Debugger commands should fail safely after completion or termination and should not call into a closed/completed session unnecessarily. +- Destructive commands such as deleting breakpoints must not be repeated implicitly by empty input. +- Evaluation commands should be clearly described as safe/limited when Ferret core exposes limited evaluation behavior. +- Keep source snippets and breakpoint messages accurate: requested location and bound location are distinct concepts. + +## Browser lifecycle rules + +- Commands should only start a browser when the selected runtime/input actually needs browser support. +- If the CLI starts a browser process, cleanup must be deterministic on normal completion, command error, and cancellation where possible. +- Platform-specific browser behavior belongs in platform-specific files. +- Avoid global mutable browser state unless the current package already owns it and tests cover it. +- Browser wait/readiness errors should explain what the user can do next. + +## Runtime and remote execution rules + +- Keep builtin runtime behavior local and direct. +- Keep remote runtime behavior explicit and network-aware. +- Do not silently fall back from remote to builtin or builtin to remote. +- Debug currently requires builtin runtime unless explicitly implemented otherwise. +- Parameter parsing should preserve JSON-aware behavior and test ambiguous cases such as strings that look like JSON values. +- Cancellation and context propagation should be preserved through runtime calls. + +## Error and diagnostic quality rules + +- User-facing errors should be specific and actionable. +- Prefer errors that name the unsupported mode and the supported alternative. +- Preserve wrapped errors where callers or tests rely on error identity. +- Avoid replacing useful Ferret core diagnostics with generic CLI errors. +- For path errors, include the relevant path when safe and useful. +- For unsupported debug modes, tell the user what to do instead, for example use the original `.fql` source file or builtin runtime. + +## Go type and file structure rules + +These rules are mandatory unless the task explicitly requires otherwise. + +- Do not define multiple method-bearing structs in the same `.go` file. +- Prefer declaring a method-bearing struct as a standalone `type Name struct { ... }`. +- A method-bearing struct should usually live in its own file, named after the primary type or responsibility whenever practical. +- Grouped `type ( ... )` declarations are allowed for interfaces, passive data-only structs, and other small related helper/value types that belong to the same narrow concern. +- A grouped `type ( ... )` block may also contain exactly one method-bearing struct when: + - it is the only behavioral type in the file, and + - the other grouped types are passive helper/value types from the same narrow concern. +- Do not use grouped `type ( ... )` declarations to hide multiple substantial behavioral types. +- If a helper struct later gains methods and would create more than one method-bearing struct in the file, extract it into its own file immediately. +- Methods for a struct should live in the same file as the struct unless there is a strong, explicit reason to split by concern. +- Do not place a new method-bearing struct into an existing file just because the code compiles. + +Allowed: + +```go +type ( + commandResult struct { + Exit bool + } + + commandHandler interface { + Handle(context.Context) commandResult + } +) +``` + +Avoid: + +```go +type ( + Runner struct { + // ... + } + + debugPrompt struct { + // ... + } +) +``` + +## Function and method ownership rules + +These rules are mandatory unless the task explicitly requires otherwise. + +- A file centered on a method-bearing type should contain the type, its methods, and its constructors only. +- Do not mix package-level helper functions into a file that already contains methods for a primary type. +- In type-centered files, constructor functions are the only normally allowed package-level functions. +- If logic conceptually belongs to the primary type, implement it as a method. +- If logic does not belong to the type and must remain a package-level function, place it in a separate helper-focused file. +- Package-level functions are preferred only when there is no natural owning type or when the behavior is genuinely package-level. +- If a file contains both methods and non-constructor package-level functions, that is usually a structure violation and should be refactored. + +## Comment rules for functions and methods + +- Do not add comments to every function or method by default. +- Exported functions and methods should usually have doc comments, especially in command-facing, package-facing, or extension-facing code. +- Unexported functions and methods should be commented only when they carry non-obvious behavior, invariants, side effects, ownership rules, cleanup expectations, or protocol/lifecycle constraints. +- Comments must explain intent, contract, invariants, side effects, or lifecycle behavior. +- Prefer comments that explain why the code exists, what must remain true, or how the method is meant to be used. +- Do not write comments that merely restate the method name or signature. +- Avoid comment wallpaper. Dense, meaningful comments are preferred over mechanically documenting obvious code. + +Preferred: + +```go +// Close releases resources associated with the browser process. +// It is safe to call multiple times. +func (p *Process) Close() error +``` + +Avoid: + +```go +// Close closes the process. +func (p *Process) Close() error +``` + +## Response and code style + +When assisting with this repository, avoid large unstructured blocks of prose or code. + +Prefer responses that are easy to scan: + +- Use short sections with clear headings. +- Use bullet points for decisions, trade-offs, and follow-up work. +- Use code blocks only for actual code, commands, or configuration. +- Prefer focused snippets or diffs over full-file dumps. +- Explain why a change is needed before showing how to implement it. +- Keep comments in code useful and minimal. +- Avoid repeating the same context in multiple places. +- When the change touches multiple files, summarize the role of each file first. + +The expected tone is practical, concise, and engineering-focused. + +## Development practice expectations + +Agents must follow repository-specific engineering discipline rather than generic style preferences. + +### Core principles + +- Preserve correctness first. +- Preserve subsystem boundaries and invariants. +- Prefer the smallest local change that fully solves the task. +- Avoid introducing abstractions, indirection, or refactors unless they are necessary for correctness, maintainability, or an explicitly requested design change. +- Do not optimize by intuition alone; use measurements for performance-sensitive work. +- Keep behavioral ownership obvious in code structure, naming, and file layout. + +### Mandatory expectations + +- Identify the owning subsystem before making a non-trivial change. +- Preserve existing behavior unless the task explicitly requires changing it. +- Add or update tests for any behavior change. +- Add or update benchmarks for any significant change. +- Run the narrowest relevant validation first, then broaden as appropriate. +- Do not claim tests, benchmarks, or validation were completed unless they were actually run. +- Do not treat historical discussions, abandoned directions, v1 behavior, or old branches as authoritative over current code and repository guidance. +- Do not perform opportunistic refactors unrelated to the requested task unless they are required for correctness. + +### Required workflow for non-trivial changes + +Before making a non-trivial change, agents must: + +1. Identify the owning subsystem. +2. Identify the contract, invariant, or behavior being preserved or changed. +3. Choose the smallest reasonable implementation that fits the existing design. +4. Determine whether the change is significant. +5. Add or update correctness tests. +6. Add or update benchmarks if the change is significant. +7. Run relevant validation and summarize the results accurately. + +### Significant changes + +A change is significant when it could reasonably affect: + +- command startup latency +- runtime execution latency +- browser startup/wait behavior +- allocation patterns on common command paths +- remote runtime request/response behavior +- build artifact generation cost +- debugger hooks or REPL command dispatch on hot execution paths +- release/install/update behavior that affects users broadly + +This usually does not include: + +- comment-only, docs-only, or formatting-only edits +- pure renames with no behavior change +- test-only changes +- command help text changes +- narrowly scoped refactors that do not affect behavior or hot paths + +When in doubt, treat the change as significant and benchmark it or explain why benchmarking is not practical. + +### Benchmark workflow for significant changes + +For significant changes, agents must: + +- run relevant benchmarks before making the change and save the results as a baseline +- implement the change +- run the same benchmarks again after the change +- compare before/after results, preferably including `ns/op`, `B/op`, and `allocs/op` +- report the benchmark command used and summarize the performance delta + +If no relevant benchmark exists for the changed hot path, add one when practical. + +If benchmark tooling or environment is unavailable, state that explicitly and do not claim benchmark validation was completed. + +## Test placement rules + +- Cobra command behavior should have tests in `cmd`. +- Runtime option and adapter behavior should have tests in `pkg/runtime`. +- Run/execute input behavior should have tests in `pkg/run`. +- Source resolution behavior should have tests in `pkg/source`. +- Browser option/lifecycle behavior should have tests in `pkg/browser` where possible. +- Debugger command parsing, REPL lifecycle, and rendering should have tests in `pkg/debugger`. +- Build artifact/path behavior should have tests in `pkg/build`. +- Config behavior should have tests in `pkg/config` if changed. +- Self-update behavior should be tested with network boundaries mocked or isolated. + +Prefer testing the owning package directly, then add command-level coverage when user-visible CLI behavior changes. + +## Validation and evidence + +When finishing a non-trivial change, agents should report: + +- owning subsystem +- files changed +- tests added or updated +- benchmarks added or updated, if applicable +- validation commands run +- benchmark commands run, if applicable +- notable invariants preserved or intentionally changed + +For significant changes: + +- tests alone are not sufficient unless benchmarking is genuinely not practical +- both correctness tests and benchmarks are expected when the environment allows them +- benchmark results should be compared against a baseline + +### Change discipline + +- Prefer adapting an existing local pattern over introducing a new architectural pattern. +- Do not add new helper layers, wrappers, interfaces, or abstractions only for aesthetic reasons. +- Do not move code across packages unless the ownership boundary is genuinely wrong. +- Keep diffs focused on the requested task. +- If a cleanup is necessary to make the requested change safe, keep it tightly scoped and explain why it was needed. + +### Comment and documentation discipline + +- Add comments where semantics, invariants, side effects, ownership, lifecycle, or recovery behavior are non-obvious. +- Do not add comment wallpaper. +- Prefer comments that explain why, contract, or invariants rather than implementation narration. +- Public and user-facing behavior should be documented more carefully than local obvious helpers. + +### Decision bias when uncertain + +When uncertain: + +- preserve existing behavior +- prefer the smaller local change +- add a focused test +- treat the change as significant if performance might be affected +- verify ownership before introducing a new abstraction or package-level dependency + +## Tooling prerequisites + +- Go must be installed at the version required by `go.mod`. +- `make` is optional but is the preferred entrypoint for repo-defined workflows. +- `staticcheck`, `goimports`, and `revive` are needed for lint/format flows; install them with `make install-tools`. +- Release work may require GoReleaser and any signing/notarization tools configured outside this repository. + +## Command matrix + +- Broad validation: `go test ./...` +- Repo test target: `make test` +- Vet: `make vet` +- Lint: `make lint` +- Format: `make fmt` +- Build the CLI binary: `make compile` +- Full local build flow: `make build` +- Release: `make release TAG=` + +Prefer narrow validation first, then broaden: + +- Package-local changes: run `go test ./pkg/` or `go test ./cmd`. +- Cross-cutting command/runtime changes: run the affected package tests, then `go test ./...` or `make test`. +- Release-sensitive changes: run `make build` when the toolchain is available. + +## Editing rules + +- Treat `Makefile`, `.github/workflows/*`, and `.goreleaser.yml` as the source of truth for validation and release flows. +- Do not add parser-generation rules to this repository; parser generation belongs to Ferret core. +- Do not vendor or duplicate Ferret core internals into the CLI. +- Keep version injection compatible with the existing `make compile` flags: + - `main.version` + - `github.com/MontFerret/cli/v2/pkg/runtime.version` +- If you change package paths used by `goimports` in `make fmt`, update the Makefile format target as part of the same change. +- If you change release scripts or GoReleaser config, verify the expected tag/version flow in `scripts/versions.sh` and `scripts/release.sh`. + +### Validation expectations + +- After code changes, run the narrowest tests that prove the behavior you touched. +- Before finishing broader changes, run the relevant repo-level command from the matrix above. +- If you changed formatting-sensitive files, run `make fmt` when available. +- If you changed lint-sensitive code paths or public behavior, run `make lint` when available. +- If the local environment cannot download the required Go toolchain or dependencies, state that explicitly and report which validation commands could not be run. + +### Expectations for non-trivial changes + +When proposing or implementing non-trivial changes: + +- identify the owning subsystem first +- preserve invariants unless the task explicitly changes them +- prefer local, comprehensible changes before introducing new abstractions +- distinguish correctness work from performance work +- do not perform opportunistic refactors unrelated to the requested task unless they are necessary for correctness + +## Secondary references + +- `README.md` for product context and user-facing command examples. +- `CHANGELOG.md` for release history. +- `.github/workflows/*` for CI behavior. +- `.goreleaser.yml` for release packaging. +- The Ferret core repository for language/compiler/VM/runtime semantics. From 68fa74ed543bf64440df19a5854d4e5d5d2c396f Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Sun, 14 Jun 2026 16:41:37 -0400 Subject: [PATCH 5/6] Enhance debugger: add repeatable commands, REPL states, and extended aliases --- cmd/debug.go | 2 +- cmd/debug_test.go | 6 +- pkg/debugger/command.go | 21 ++- pkg/debugger/command_test.go | 7 + pkg/debugger/prompt.go | 89 +++++++++-- pkg/debugger/prompt_test.go | 284 +++++++++++++++++++++++++++++++++-- pkg/debugger/render.go | 26 ++-- pkg/debugger/render_test.go | 22 +++ pkg/runtime/debug_test.go | 3 + pkg/runtime/errors.go | 2 +- 10 files changed, 414 insertions(+), 48 deletions(-) diff --git a/cmd/debug.go b/cmd/debug.go index c380fa3..9aef800 100644 --- a/cmd/debug.go +++ b/cmd/debug.go @@ -56,7 +56,7 @@ func executeDebug(cmd *cobra.Command, rtOpts cliruntime.Options, brOpts browser. } if input != nil && len(input.Artifact) > 0 { - return fmt.Errorf("debugging compiled artifacts is not supported") + return fmt.Errorf("debugging compiled artifacts is not supported yet; run debug with the original .fql source file") } if input == nil || input.Source == nil { diff --git a/cmd/debug_test.go b/cmd/debug_test.go index c627ac8..c99f39e 100644 --- a/cmd/debug_test.go +++ b/cmd/debug_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "path/filepath" - "strings" "testing" "github.com/MontFerret/ferret/v2/pkg/compiler" @@ -44,6 +43,9 @@ func TestExecuteDebugRejectsRemoteRuntimeBeforeStarting(t *testing.T) { if !errors.Is(err, cliruntime.ErrDebugRequiresBuiltinRuntime) { t.Fatalf("expected builtin runtime error, got %v", err) } + if got := err.Error(); got != "debug currently supports only the builtin runtime" { + t.Fatalf("unexpected builtin runtime error: %q", got) + } } func TestExecuteDebugRejectsArtifact(t *testing.T) { @@ -62,7 +64,7 @@ func TestExecuteDebugRejectsArtifact(t *testing.T) { nil, []string{artifactPath}, ) - if err == nil || !strings.Contains(err.Error(), "compiled artifacts") { + if err == nil || err.Error() != "debugging compiled artifacts is not supported yet; run debug with the original .fql source file" { t.Fatalf("unexpected error: %v", err) } } diff --git a/pkg/debugger/command.go b/pkg/debugger/command.go index 11cd9f5..1799d5f 100644 --- a/pkg/debugger/command.go +++ b/pkg/debugger/command.go @@ -38,13 +38,20 @@ type Command struct { } var aliases = map[string]CommandName{ - "b": CommandBreak, - "c": CommandContinue, - "s": CommandStep, - "n": CommandNext, - "bt": CommandWhere, - "p": CommandPrint, - "q": CommandQuit, + "b": CommandBreak, + "d": CommandDelete, + "bp": CommandBreakpoints, + "bl": CommandBreakpoints, + "c": CommandContinue, + "s": CommandStep, + "n": CommandNext, + "w": CommandWhere, + "bt": CommandWhere, + "l": CommandLocals, + "p": CommandPrint, + "e": CommandPrint, + "eval": CommandPrint, + "q": CommandQuit, } func ParseCommand(input string) (Command, error) { diff --git a/pkg/debugger/command_test.go b/pkg/debugger/command_test.go index fa14f2e..3ec74b0 100644 --- a/pkg/debugger/command_test.go +++ b/pkg/debugger/command_test.go @@ -121,7 +121,14 @@ func TestParseCommand(t *testing.T) { {name: "continue alias", input: "c", want: Command{Name: CommandContinue}}, {name: "step alias", input: "s", want: Command{Name: CommandStep}}, {name: "next alias", input: "n", want: Command{Name: CommandNext}}, + {name: "delete alias", input: "d 1", want: Command{Name: CommandDelete, Argument: "1", BreakpointID: ferret.DebugBreakpointID(1)}}, + {name: "breakpoints bp alias", input: "bp", want: Command{Name: CommandBreakpoints}}, + {name: "breakpoints bl alias", input: "bl", want: Command{Name: CommandBreakpoints}}, + {name: "where w alias", input: "w", want: Command{Name: CommandWhere}}, {name: "where alias", input: "bt", want: Command{Name: CommandWhere}}, + {name: "locals alias", input: "l", want: Command{Name: CommandLocals}}, + {name: "print e alias", input: "e user.name", want: Command{Name: CommandPrint, Argument: "user.name"}}, + {name: "print eval alias", input: "eval user.name", want: Command{Name: CommandPrint, Argument: "user.name"}}, {name: "quit alias", input: "q", want: Command{Name: CommandQuit}}, {name: "invalid command", input: "wat", errHas: "unknown command: wat"}, {name: "missing break line", input: "break", errHas: "usage: break"}, diff --git a/pkg/debugger/prompt.go b/pkg/debugger/prompt.go index 4c52548..8f4b0ca 100644 --- a/pkg/debugger/prompt.go +++ b/pkg/debugger/prompt.go @@ -14,6 +14,15 @@ import ( "github.com/MontFerret/ferret/v2/pkg/source" ) +type replState int + +const ( + replStateReady replState = iota + replStatePaused + replStateCompleted + replStateTerminated +) + func Start(ctx context.Context, session Session, src *source.Source) error { rl, err := readline.NewEx(&readline.Config{ Prompt: "(fdb) ", @@ -46,8 +55,12 @@ func Run(ctx context.Context, session Session, src *source.Source, input LineRea } renderer.Event(event) + state := nextReplState(replStateReady, event) + fmt.Fprintln(out, `Type "help" for available commands.`) + var repeatCommand Command + for { line, readErr := input.Readline() @@ -72,10 +85,24 @@ func Run(ctx context.Context, session Session, src *source.Source, input LineRea } if command.Name == CommandEmpty { + if repeatCommand.Name == CommandEmpty { + continue + } + + command = repeatCommand + } else if isRepeatableCommand(command.Name) { + repeatCommand = command + } else { + repeatCommand = Command{} + } + + if message := unavailableCommandMessage(state, command.Name); message != "" { + fmt.Fprintln(out, message) continue } - quit := executeCommand(ctx, session, src.Name(), renderer, command) + quit, event := executeCommand(ctx, session, src.Name(), renderer, command) + state = nextReplState(state, event) if quit { fmt.Fprintln(out, "Debug session terminated.") @@ -84,7 +111,7 @@ func Run(ctx context.Context, session Session, src *source.Source, input LineRea } } -func executeCommand(ctx context.Context, session Session, mainFile string, renderer *Renderer, command Command) bool { +func executeCommand(ctx context.Context, session Session, mainFile string, renderer *Renderer, command Command) (bool, *ferret.DebugEvent) { switch command.Name { case CommandHelp: renderer.Help() @@ -116,16 +143,16 @@ func executeCommand(ctx context.Context, session Session, mainFile string, rende renderer.Breakpoints(session.Breakpoints()) case CommandContinue: event, err := session.Continue(ctx) - renderResume(event, err, renderer) + return false, renderResume(event, err, renderer) case CommandStep: event, err := session.Step(ctx) - renderResume(event, err, renderer) + return false, renderResume(event, err, renderer) case CommandNext: event, err := session.Next(ctx) - renderResume(event, err, renderer) + return false, renderResume(event, err, renderer) case CommandOut: event, err := session.Out(ctx) - renderResume(event, err, renderer) + return false, renderResume(event, err, renderer) case CommandPause: if err := session.Pause(); err != nil { renderer.Error("Pause error", err) @@ -157,17 +184,59 @@ func executeCommand(ctx context.Context, session Session, mainFile string, rende renderer.Evaluation(value) } case CommandQuit: - return true + return true, nil } - return false + return false, nil } -func renderResume(event *ferret.DebugEvent, err error, renderer *Renderer) { +func renderResume(event *ferret.DebugEvent, err error, renderer *Renderer) *ferret.DebugEvent { if err != nil { renderer.Error("Debugger error", err) - return + return nil } renderer.Event(event) + return event +} + +func nextReplState(current replState, event *ferret.DebugEvent) replState { + if event == nil { + return current + } + + switch event.Reason { + case ferret.DebugReasonCompleted: + return replStateCompleted + case ferret.DebugReasonTerminated: + return replStateTerminated + default: + return replStatePaused + } +} + +func isRepeatableCommand(name CommandName) bool { + switch name { + case CommandContinue, CommandStep, CommandNext, CommandOut: + return true + default: + return false + } +} + +func unavailableCommandMessage(state replState, name CommandName) string { + switch name { + case CommandContinue, CommandStep, CommandNext, CommandOut, CommandPause, CommandWhere, CommandLocals, CommandPrint: + default: + return "" + } + + switch state { + case replStateCompleted: + return "Program has completed. Start a new debug session to continue debugging." + case replStateTerminated: + return "Program has terminated. Start a new debug session to continue debugging." + default: + return "" + } } diff --git a/pkg/debugger/prompt_test.go b/pkg/debugger/prompt_test.go index a0efc5e..ed57a1b 100644 --- a/pkg/debugger/prompt_test.go +++ b/pkg/debugger/prompt_test.go @@ -47,7 +47,8 @@ func TestRunDispatchesCommandsAndClosesSession(t *testing.T) { t.Fatal(err) } - if !session.started || !session.paused || !session.continued || !session.stepped || !session.nexted || !session.outed || session.closeCalls != 1 { + if session.startCalls != 1 || session.pauseCalls != 1 || session.continueCalls != 1 || session.stepCalls != 1 || + session.nextCalls != 1 || session.outCalls != 1 || session.closeCalls != 1 { t.Fatalf("unexpected session calls: %#v", session) } if session.breakpointLocation != (ferret.DebugSourceLocation{File: "demo.fql", Line: 2, Column: 1}) { @@ -137,6 +138,251 @@ func TestRunEOFAndCloseError(t *testing.T) { } } +func TestRunRepeatsPreviousResumeCommandOnEmptyInput(t *testing.T) { + tests := []struct { + name string + command string + calls func(*fakeSession) int + }{ + {name: "next", command: "next", calls: func(session *fakeSession) int { return session.nextCalls }}, + {name: "step alias", command: "s", calls: func(session *fakeSession) int { return session.stepCalls }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + session := &fakeSession{ + startEvent: debugEvent(ferret.DebugReasonEntry, "demo.fql", 1, source.Span{Start: 0, End: 1}), + continueEvent: debugEvent(ferret.DebugReasonStep, "demo.fql", 2, source.Span{Start: 9, End: 10}), + } + + err := Run(context.Background(), session, source.New("demo.fql", "RETURN 1"), &fakeLineReader{ + results: []lineResult{{line: test.command}, {line: ""}, {line: "q"}}, + }, io.Discard) + if err != nil { + t.Fatal(err) + } + if got := test.calls(session); got != 2 { + t.Fatalf("expected repeated %s call, got %d calls", test.command, got) + } + }) + } +} + +func TestRunEmptyInputDoesNotRepeatNonRepeatableCommands(t *testing.T) { + tests := []struct { + name string + command string + check func(*testing.T, *fakeSession) + }{ + { + name: "where clears previous resume and is not repeated", + command: "where", + check: func(t *testing.T, session *fakeSession) { + t.Helper() + if session.nextCalls != 1 || session.framesCalls != 1 { + t.Fatalf("unexpected session calls: %#v", session) + } + }, + }, + { + name: "delete clears previous resume and is not repeated", + command: "delete 99", + check: func(t *testing.T, session *fakeSession) { + t.Helper() + if session.nextCalls != 1 || session.deleteCalls != 1 { + t.Fatalf("unexpected session calls: %#v", session) + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + session := &fakeSession{ + startEvent: debugEvent(ferret.DebugReasonEntry, "demo.fql", 1, source.Span{Start: 0, End: 1}), + continueEvent: debugEvent(ferret.DebugReasonStep, "demo.fql", 1, source.Span{Start: 0, End: 1}), + } + + err := Run(context.Background(), session, source.New("demo.fql", "RETURN 1"), &fakeLineReader{ + results: []lineResult{{line: "next"}, {line: test.command}, {line: ""}, {line: "q"}}, + }, io.Discard) + if err != nil { + t.Fatal(err) + } + test.check(t, session) + }) + } +} + +func TestRunParseErrorsDoNotClearRepeatCommand(t *testing.T) { + session := &fakeSession{ + startEvent: debugEvent(ferret.DebugReasonEntry, "demo.fql", 1, source.Span{Start: 0, End: 1}), + continueEvent: debugEvent(ferret.DebugReasonStep, "demo.fql", 1, source.Span{Start: 0, End: 1}), + } + + err := Run(context.Background(), session, source.New("demo.fql", "RETURN 1"), &fakeLineReader{ + results: []lineResult{{line: "next"}, {line: "wat"}, {line: ""}, {line: "q"}}, + }, io.Discard) + if err != nil { + t.Fatal(err) + } + if session.nextCalls != 2 { + t.Fatalf("expected parse error to preserve repeat command, got %d next calls", session.nextCalls) + } +} + +func TestRunBlocksCommandsAfterCompletionAndKeepsSafeCommandsAvailable(t *testing.T) { + session := &fakeSession{ + startEvent: debugEvent(ferret.DebugReasonEntry, "demo.fql", 1, source.Span{Start: 0, End: 1}), + continueEvent: debugEvent(ferret.DebugReasonCompleted, "", 0, source.Span{}), + } + var out bytes.Buffer + + err := Run(context.Background(), session, source.New("demo.fql", "RETURN 1"), &fakeLineReader{ + results: []lineResult{ + {line: "continue"}, + {line: ""}, + {line: "step"}, + {line: "next"}, + {line: "out"}, + {line: "pause"}, + {line: "where"}, + {line: "locals"}, + {line: "print 1"}, + {line: "help"}, + {line: "break 1"}, + {line: "breakpoints"}, + {line: "delete 1"}, + {line: "q"}, + }, + }, &out) + if err != nil { + t.Fatal(err) + } + + if session.continueCalls != 1 || session.stepCalls != 0 || session.nextCalls != 0 || session.outCalls != 0 || + session.pauseCalls != 0 || session.framesCalls != 0 || session.localsCalls != 0 || session.evaluateCalls != 0 { + t.Fatalf("terminal commands reached session: %#v", session) + } + if session.setBreakpointCalls != 1 || session.breakpointsCalls != 1 || session.deleteCalls != 1 || session.closeCalls != 1 { + t.Fatalf("safe commands were not dispatched: %#v", session) + } + + got := out.String() + for _, expected := range []string{ + "Program completed.", + "Program has completed. Start a new debug session to continue debugging.", + "Commands:", + "Breakpoint 1 set", + "Breakpoint 1 deleted.", + "Debug session terminated.", + } { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in %q", expected, got) + } + } +} + +func TestRunBlocksResumeCommandsAfterTermination(t *testing.T) { + session := &fakeSession{ + startEvent: debugEvent(ferret.DebugReasonEntry, "demo.fql", 1, source.Span{Start: 0, End: 1}), + continueEvent: debugEvent(ferret.DebugReasonTerminated, "", 0, source.Span{}), + } + var out bytes.Buffer + + err := Run(context.Background(), session, source.New("demo.fql", "RETURN 1"), &fakeLineReader{ + results: []lineResult{ + {line: "continue"}, + {line: ""}, + {line: "step"}, + {line: "next"}, + {line: "out"}, + {line: "pause"}, + {line: "where"}, + {line: "locals"}, + {line: "print 1"}, + {line: "q"}, + }, + }, &out) + if err != nil { + t.Fatal(err) + } + + if session.continueCalls != 1 || session.stepCalls != 0 || session.nextCalls != 0 || session.outCalls != 0 || + session.pauseCalls != 0 || session.framesCalls != 0 || session.localsCalls != 0 || session.evaluateCalls != 0 { + t.Fatalf("terminal commands reached session: %#v", session) + } + if got := out.String(); !strings.Contains(got, "Program has terminated. Start a new debug session to continue debugging.") { + t.Fatalf("unexpected output: %q", got) + } +} + +func TestRunTracksTerminalInitialEvent(t *testing.T) { + tests := []struct { + name string + reason ferret.DebugReason + message string + }{ + {name: "completed", reason: ferret.DebugReasonCompleted, message: "Program has completed. Start a new debug session to continue debugging."}, + {name: "terminated", reason: ferret.DebugReasonTerminated, message: "Program has terminated. Start a new debug session to continue debugging."}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + session := &fakeSession{startEvent: debugEvent(test.reason, "", 0, source.Span{})} + var out bytes.Buffer + + err := Run(context.Background(), session, source.New("demo.fql", "RETURN 1"), &fakeLineReader{ + results: []lineResult{{line: "continue"}, {line: "q"}}, + }, &out) + if err != nil { + t.Fatal(err) + } + if session.continueCalls != 0 { + t.Fatalf("continue reached terminal session: %#v", session) + } + if got := out.String(); !strings.Contains(got, test.message) { + t.Fatalf("unexpected output: %q", got) + } + }) + } +} + +func TestRunKeepsRuntimeErrorEventActive(t *testing.T) { + session := &fakeSession{ + startEvent: debugEvent(ferret.DebugReasonRuntimeError, "demo.fql", 1, source.Span{Start: 0, End: 1}), + continueEvent: debugEvent(ferret.DebugReasonStep, "demo.fql", 1, source.Span{Start: 0, End: 1}), + } + + err := Run(context.Background(), session, source.New("demo.fql", "RETURN 1"), &fakeLineReader{ + results: []lineResult{{line: "next"}, {line: "q"}}, + }, io.Discard) + if err != nil { + t.Fatal(err) + } + if session.nextCalls != 1 { + t.Fatalf("expected next after runtime error, got %d calls", session.nextCalls) + } +} + +func TestRunResumeErrorPreservesState(t *testing.T) { + session := &fakeSession{ + startEvent: debugEvent(ferret.DebugReasonEntry, "demo.fql", 1, source.Span{Start: 0, End: 1}), + continueEvent: debugEvent(ferret.DebugReasonCompleted, "", 0, source.Span{}), + continueErr: errors.New("resume failed"), + } + + err := Run(context.Background(), session, source.New("demo.fql", "RETURN 1"), &fakeLineReader{ + results: []lineResult{{line: "continue"}, {line: "next"}, {line: "q"}}, + }, io.Discard) + if err != nil { + t.Fatal(err) + } + if session.continueCalls != 1 || session.nextCalls != 1 { + t.Fatalf("expected resume error to preserve active state: %#v", session) + } +} + type lineResult struct { line string err error @@ -169,46 +415,53 @@ type fakeSession struct { closeErr error breakpointLocation ferret.DebugSourceLocation breakpointOptions ferret.DebugBreakpointOptions - started bool - continued bool - stepped bool - nexted bool - outed bool - paused bool + startCalls int + continueCalls int + stepCalls int + nextCalls int + outCalls int + pauseCalls int + setBreakpointCalls int + deleteCalls int + breakpointsCalls int + framesCalls int + localsCalls int + evaluateCalls int closeCalls int } func (f *fakeSession) Start(context.Context) (*ferret.DebugEvent, error) { - f.started = true + f.startCalls++ return f.startEvent, nil } func (f *fakeSession) Continue(context.Context) (*ferret.DebugEvent, error) { - f.continued = true + f.continueCalls++ return f.continueEvent, f.continueErr } func (f *fakeSession) Step(context.Context) (*ferret.DebugEvent, error) { - f.stepped = true + f.stepCalls++ return f.continueEvent, nil } func (f *fakeSession) Next(context.Context) (*ferret.DebugEvent, error) { - f.nexted = true + f.nextCalls++ return f.continueEvent, nil } func (f *fakeSession) Out(context.Context) (*ferret.DebugEvent, error) { - f.outed = true + f.outCalls++ return f.continueEvent, nil } func (f *fakeSession) Pause() error { - f.paused = true + f.pauseCalls++ return nil } func (f *fakeSession) SetBreakpointAt(location ferret.DebugSourceLocation, options ferret.DebugBreakpointOptions) (ferret.DebugBreakpoint, error) { + f.setBreakpointCalls++ f.breakpointLocation = location f.breakpointOptions = options breakpoint := ferret.DebugBreakpoint{ @@ -226,6 +479,7 @@ func (f *fakeSession) SetBreakpointAt(location ferret.DebugSourceLocation, optio } func (f *fakeSession) DeleteBreakpoint(id ferret.DebugBreakpointID) error { + f.deleteCalls++ for i, breakpoint := range f.breakpoints { if breakpoint.ID == id { f.breakpoints = append(f.breakpoints[:i], f.breakpoints[i+1:]...) @@ -236,18 +490,22 @@ func (f *fakeSession) DeleteBreakpoint(id ferret.DebugBreakpointID) error { } func (f *fakeSession) Breakpoints() []ferret.DebugBreakpoint { + f.breakpointsCalls++ return f.breakpoints } func (f *fakeSession) Frames() ([]ferret.DebugFrame, error) { + f.framesCalls++ return f.frames, nil } func (f *fakeSession) Locals() ([]ferret.DebugVariable, error) { + f.localsCalls++ return f.locals, nil } func (f *fakeSession) Evaluate(_ context.Context, expression string) (ferret.DebugValue, error) { + f.evaluateCalls++ f.expression = expression return f.evaluation, f.evaluateErr } diff --git a/pkg/debugger/render.go b/pkg/debugger/render.go index 1aaabce..9ec4834 100644 --- a/pkg/debugger/render.go +++ b/pkg/debugger/render.go @@ -13,25 +13,23 @@ import ( ) const helpText = `Commands: - break Set at next executable location in file + break, b Set at next executable location in file break --exact Set only at the exact executable location break --next Set at next executable location in file break --in-function Set at next executable location in function - breakpoints List breakpoints - delete Delete breakpoint - continue Resume execution - step Step into next source location - next Step over current source location + breakpoints, bp, bl List breakpoints + delete, d Delete breakpoint + continue, c Resume execution + step, s Step into next source location + next, n Step over current source location out Step out of current frame - pause Pause at the next source location - where Show stack trace - locals Show local variables - print Evaluate a safe expression (no calls, queries, or mutation) - quit Stop debugging and exit + pause Request pause after the next resume + where, w, bt Show stack trace + locals, l Show local variables + print, p, eval, e Evaluate a safe expression (no calls, queries, or mutation) + quit, q Stop debugging and exit -Locations: 12, 12:4, file.fql:12, file.fql:12:4 - -Aliases: b=break, c=continue, s=step, n=next, bt=where, p=print, q=quit` +Locations: 12, 12:4, file.fql:12, file.fql:12:4` type Renderer struct { out io.Writer diff --git a/pkg/debugger/render_test.go b/pkg/debugger/render_test.go index 09ce776..591061b 100644 --- a/pkg/debugger/render_test.go +++ b/pkg/debugger/render_test.go @@ -133,3 +133,25 @@ func TestRendererEmptyCollections(t *testing.T) { } } } + +func TestRendererHelpIncludesAliasesAndPauseBehavior(t *testing.T) { + var out bytes.Buffer + renderer := NewRenderer(&out, nil) + + renderer.Help() + + got := out.String() + for _, expected := range []string{ + "break, b ", + "delete, d ", + "breakpoints, bp, bl", + "where, w, bt", + "locals, l", + "print, p, eval, e ", + "Request pause after the next resume", + } { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in %q", expected, got) + } + } +} diff --git a/pkg/runtime/debug_test.go b/pkg/runtime/debug_test.go index 5d4124f..8346c31 100644 --- a/pkg/runtime/debug_test.go +++ b/pkg/runtime/debug_test.go @@ -159,4 +159,7 @@ func TestNewDebugSessionRejectsRemoteRuntime(t *testing.T) { if !errors.As(err, &typed) { t.Fatalf("expected typed builtin runtime error, got %T", err) } + if got := err.Error(); got != "debug currently supports only the builtin runtime" { + t.Fatalf("unexpected builtin runtime error: %q", got) + } } diff --git a/pkg/runtime/errors.go b/pkg/runtime/errors.go index 657a2ac..059ea3d 100644 --- a/pkg/runtime/errors.go +++ b/pkg/runtime/errors.go @@ -10,7 +10,7 @@ var ErrArtifactRequiresBuiltinRuntime = errors.New("compiled artifacts require t type DebugRequiresBuiltinRuntimeError struct{} func (*DebugRequiresBuiltinRuntimeError) Error() string { - return "debugging requires the builtin runtime" + return "debug currently supports only the builtin runtime" } // ErrDebugRequiresBuiltinRuntime indicates source debugging is only available From 5036ed44d434bb772862b52692bcfd1d409c9e22 Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Sun, 14 Jun 2026 16:42:55 -0400 Subject: [PATCH 6/6] Update Makefile: adjust `goimports` paths and streamline `revive` exclusions --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index fd5aa9f..a10b84c 100644 --- a/Makefile +++ b/Makefile @@ -25,11 +25,11 @@ test: fmt: go fmt ./... && \ - goimports -w -local github.com/MontFerret ./pkg/browser ./cmd ./internal/debugcli ./pkg/config ./ferret ./pkg/logger ./pkg/repl ./pkg/runtime + goimports -w -local github.com/MontFerret ./pkg/browser ./cmd ./pkg/debugger ./pkg/config ./ferret ./pkg/logger ./pkg/repl ./pkg/runtime lint: staticcheck ./... && \ - revive -config revive.toml -formatter stylish -exclude ./pkg/parser/fql/... -exclude ./vendor/... ./... + revive -config revive.toml -formatter stylish -exclude ./vendor/... ./... vet: