From 7d94dee26ba3ed0f70a74665782f56bce601c929 Mon Sep 17 00:00:00 2001 From: pablu Date: Sat, 28 Mar 2026 16:12:33 +0100 Subject: [PATCH] Initial, working but only with viewport height adjusted a little bit --- .direnv/flake-profile | 1 + .direnv/flake-profile-3-link | 1 + .envrc | 1 + cmd/pybug/main.go | 56 +++++++ flake.lock | 59 ++++++++ flake.nix | 22 +++ go.mod | 34 +++++ go.sum | 60 ++++++++ internal/bridge/client.go | 273 +++++++++++++++++++++++++++++++++++ internal/bridge/protocol.go | 42 ++++++ python/pybug_runtime.py | 114 +++++++++++++++ test.py | 10 ++ ui/model.go | 57 ++++++++ ui/program.go | 21 +++ ui/update.go | 50 +++++++ ui/view.go | 56 +++++++ 16 files changed, 857 insertions(+) create mode 120000 .direnv/flake-profile create mode 120000 .direnv/flake-profile-3-link create mode 100644 .envrc create mode 100644 cmd/pybug/main.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/bridge/client.go create mode 100644 internal/bridge/protocol.go create mode 100644 python/pybug_runtime.py create mode 100644 test.py create mode 100644 ui/model.go create mode 100644 ui/program.go create mode 100644 ui/update.go create mode 100644 ui/view.go diff --git a/.direnv/flake-profile b/.direnv/flake-profile new file mode 120000 index 0000000..519b17b --- /dev/null +++ b/.direnv/flake-profile @@ -0,0 +1 @@ +flake-profile-3-link \ No newline at end of file diff --git a/.direnv/flake-profile-3-link b/.direnv/flake-profile-3-link new file mode 120000 index 0000000..f1e6e50 --- /dev/null +++ b/.direnv/flake-profile-3-link @@ -0,0 +1 @@ +/nix/store/x4pks9q49xlz3by0czlhc7cr3624k6yf-nix-shell-env \ No newline at end of file diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/cmd/pybug/main.go b/cmd/pybug/main.go new file mode 100644 index 0000000..c76b67f --- /dev/null +++ b/cmd/pybug/main.go @@ -0,0 +1,56 @@ +package main + +import ( + // "fmt" + // "log/slog" + // "time" + + "log/slog" + + b "git.pablu.de/pablu/pybug/internal/bridge" + "git.pablu.de/pablu/pybug/ui" +) + +func main() { + // slog.SetLogLoggerLevel(slog.LevelDebug) + + // + // fmt.Println("Started bridge") + // + // err = bridge.Breakpoint("test.py", 5) + // bridge.OnBreakpoint("test.py", 5, func() { + // locals, err := bridge.Locals() + // if err != nil { + // slog.Error("Encountered error on callback", "error", err) + // return + // } + // + // for key, val := range locals { + // slog.Info("found local variable", "key", key, "value", val) + // } + // }) + // + // bridge.Continue() + // + // time.Sleep(5 * time.Second) + // + // bridge.Continue() + // + // err = bridge.Wait() + // if err != nil { + // panic(err) + // } + + slog.SetLogLoggerLevel(slog.LevelError) + + bridge := b.NewBridge("test.py") + err := bridge.Start() + if err != nil { + panic(err) + } + + err = ui.Run(bridge) + if err != nil { + panic(err) + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..e368c39 --- /dev/null +++ b/flake.lock @@ -0,0 +1,59 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1774273680, + "narHash": "sha256-a++tZ1RQsDb1I0NHrFwdGuRlR5TORvCEUksM459wKUA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fdc7b8f7b30fdbedec91b71ed82f36e1637483ed", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "type": "indirect" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "utils": "utils" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..663b6d8 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + inputs = { + utils.url = "github:numtide/flake-utils"; + }; + outputs = { self, nixpkgs, utils }: utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + devShell = pkgs.mkShell { + buildInputs = with pkgs; [ + go + gopls + python3 + basedpyright + isort + black + ]; + }; + } + ); +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2a662f8 --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module git.pablu.de/pablu/pybug + +go 1.26.1 + +require ( + github.com/alecthomas/chroma/v2 v2.23.1 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d57ae33 --- /dev/null +++ b/go.sum @@ -0,0 +1,60 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/internal/bridge/client.go b/internal/bridge/client.go new file mode 100644 index 0000000..89087b2 --- /dev/null +++ b/internal/bridge/client.go @@ -0,0 +1,273 @@ +package bridge + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os/exec" + "sync" +) + +type Bridge struct { + stdin io.Writer + stdout *bufio.Reader + + cmd *exec.Cmd + + input chan string + + outputLock *sync.RWMutex + output []chan string + + registry map[string]chan string + registryLock *sync.RWMutex + + breakpoints map[string][]int + + callbacksLock *sync.RWMutex + callbacks map[string]map[int]func() +} + +func NewBridge(path string) *Bridge { + cmd := exec.Command( + "python", + "-u", + "python/pybug_runtime.py", + path, + ) + + return &Bridge{ + cmd: cmd, + input: make(chan string), + registry: make(map[string]chan string), + registryLock: &sync.RWMutex{}, + breakpoints: make(map[string][]int), + + callbacksLock: &sync.RWMutex{}, + callbacks: make(map[string]map[int]func()), + + output: make([]chan string, 0), + outputLock: &sync.RWMutex{}, + } +} + +func (b *Bridge) Start() error { + var err error + b.stdin, err = b.cmd.StdinPipe() + if err != nil { + return err + } + // b.cmd.Stdout = os.Stdout + + reader, err := b.cmd.StdoutPipe() + if err != nil { + return err + } + b.stdout = bufio.NewReader(reader) + + err = b.cmd.Start() + if err != nil { + return err + } + + go b.readLoop() + go b.writeLoop() + + return nil +} + +func (b *Bridge) Subscribe() chan string { + b.outputLock.Lock() + defer b.outputLock.Unlock() + + c := make(chan string) + b.output = append(b.output, c) + + return c +} + +func (b *Bridge) Locals() (map[string]any, error) { + requestId, cmd := makeCommand(LocalsCommand, map[string]any{}) + + c := b.sendCommand(requestId, cmd) + + obj := <-c + + var m map[string]any + err := json.Unmarshal([]byte(obj), &m) + if err != nil { + return nil, err + } + + vars, ok := m["vars"].(map[string]any) + if !ok { + return nil, errors.New("could not extract vars from response") + } + + return vars, nil +} + +func (b *Bridge) Breakpoint(file string, line int) error { + // Check if breakpoint already exists here + + requestId, cmd := makeCommand(BreakCommand, map[string]any{ + "file": file, + "line": line, + }) + + c := b.sendCommand(requestId, cmd) + + obj := <-c + + var m map[string]any + err := json.Unmarshal([]byte(obj), &m) + if err != nil { + return err + } + + if m["status"] != "ok" { + return fmt.Errorf("error occured on break, err: %s", m["error"]) + } + + b.breakpoints[file] = append(b.breakpoints[file], line) + + return nil +} + +func (b *Bridge) Continue() { + _, cmd := makeCommand(ContinueCommand, map[string]any{}) + + b.sendCommandNoResponse(cmd) +} + +func (b *Bridge) sendCommandNoResponse(command string) { + b.input <- command +} + +func (b *Bridge) sendCommand(requestId string, command string) chan string { + b.registryLock.Lock() + defer b.registryLock.Unlock() + + channel := make(chan string) + + b.registry[requestId] = channel + b.input <- command + + return channel +} + +func (b *Bridge) writeLoop() { + slog.Debug("started writeLoop") + defer slog.Debug("writeLoop exited") + for { + cmd := <-b.input + + slog.Info("Received command", "cmd", cmd) + + _, err := b.stdin.Write([]byte(cmd + "\n")) + if err != nil { + slog.Error("Error occured while writing to stdin", "error", err) + } + + slog.Debug("Command written", "cmd", cmd) + } +} + +func (b *Bridge) readLoop() { + slog.Debug("started readLoop") + defer slog.Debug("readLoop exited") + for { + slog.Debug("reading string from stdout waiting for newline") + line, err := b.stdout.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + slog.Error("Error occured while reading from stdout", "error", err) + continue + } else if errors.Is(err, io.EOF) { + return + } + + var msg map[string]any + err = json.Unmarshal([]byte(line), &msg) + if err != nil { + slog.Debug("read line from stdout", "line", line) + + b.outputLock.RLock() + for _, c := range b.output { + c <- line + } + b.outputLock.RUnlock() + + } else if requestId, ok := msg["request_id"].(string); ok { + b.registryLock.RLock() + c, ok := b.registry[requestId] + b.registryLock.RUnlock() + + if !ok { + slog.Error("Could not find requestId in registry", "requestId", requestId) + continue + } + + c <- line + } else { + // TODO: set to stopped + if event, ok := msg["event"]; !ok || event != "stopped" { + slog.Warn("received unkown event", "msg", msg) + } + + file := msg["file"].(string) + line, ok := toInt(msg["line"]) + if !ok { + slog.Error("could not convert line to int", "line", msg["line"]) + } + + slog.Info("received stopped event") + b.callbacksLock.RLock() + if callback, ok := b.callbacks[file][line]; ok { + slog.Info("found callback, now running", "file", file, "line", line) + go callback() + } + b.callbacksLock.RUnlock() + } + } +} + +func toInt(v any) (int, bool) { + switch x := v.(type) { + case int: + return x, true + case int8: + return int(x), true + case int16: + return int(x), true + case int32: + return int(x), true + case int64: + return int(x), true + case float32: + return int(x), true + case float64: + return int(x), true + default: + return 0, false + } +} + +func (b *Bridge) OnBreakpoint(file string, line int, callback func()) { + b.callbacksLock.Lock() + defer b.callbacksLock.Unlock() + + if f, ok := b.callbacks[file]; ok { + f[line] = callback + } else { + b.callbacks[file] = map[int]func(){ + line: callback, + } + } +} + +func (b *Bridge) Wait() error { + return b.cmd.Wait() +} diff --git a/internal/bridge/protocol.go b/internal/bridge/protocol.go new file mode 100644 index 0000000..997994a --- /dev/null +++ b/internal/bridge/protocol.go @@ -0,0 +1,42 @@ +package bridge + +import ( + "encoding/json" + "maps" + "math/rand" +) + +type CommandType string + +const ( + ContinueCommand CommandType = "continue" + BreakCommand CommandType = "break" + LocalsCommand CommandType = "locals" +) + +func makeCommand(cmd CommandType, values map[string]any) (requestId string, request string) { + m := make(map[string]any, len(values)+2) + maps.Copy(m, values) + + requestId = randString(8) + + m["cmd"] = cmd + m["request_id"] = requestId + + b, err := json.Marshal(m) + if err != nil { + panic("failed to marshal command " + err.Error()) + } + + return requestId, string(b) +} + +const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +func randString(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = letterBytes[rand.Intn(len(letterBytes))] + } + return string(b) +} diff --git a/python/pybug_runtime.py b/python/pybug_runtime.py new file mode 100644 index 0000000..9076405 --- /dev/null +++ b/python/pybug_runtime.py @@ -0,0 +1,114 @@ +import bdb +import json +import sys +from types import FrameType + + +class PyBugBridgeDebugger(bdb.Bdb): + def __init__(self): + super().__init__() + self.waiting = False + + def send(self, msg: dict): + sys.stdout.write(json.dumps(msg) + "\n") + sys.stdout.flush() + + def recv(self) -> dict: + return json.loads(sys.stdin.readline()) + + def user_line(self, frame: FrameType): + # print("TRACE:", frame.f_code.co_filename, frame.f_lineno) + self.send( + { + "event": "stopped", + "file": frame.f_code.co_filename, + "line": frame.f_lineno, + } + ) + + self.interaction_loop(frame) + + def interaction_loop(self, frame: FrameType): + while True: + cmd = self.recv() + + match cmd["cmd"]: + case "continue": + return self.set_continue() + case "step": + return self.set_step() + case "next": + return self.set_next(frame) + case "locals": + self.send( + { + "request_id": cmd["request_id"], + "event": "locals", + "vars": {k: repr(v) for k, v in frame.f_locals.items()}, + } + ) + case "eval": + try: + result = eval(cmd["expr"], frame.f_globals, frame.f_locals) + self.send( + { + "request_id": cmd["request_id"], + "event": "eval", + "status": "ok", + "value": repr(result), + } + ) + except Exception as e: + self.send( + { + "request_id": cmd["request_id"], + "event": "eval", + "status": "error", + "value": str(e), + } + ) + case "break": + err = self.set_break(cmd["file"], cmd["line"]) + if err: + self.send( + { + "request_id": cmd["request_id"], + "event": "break", + "status": "error", + "error": err, + } + ) + else: + self.send( + { + "request_id": cmd["request_id"], + "event": "break", + "status": "ok", + } + ) + + +def main(): + if len(sys.argv) < 2: + print("Usage: pydbug_runtime ") + + script = sys.argv[1] + sys.argv = sys.argv[1:] + + dbg = PyBugBridgeDebugger() + + with open(script, "rb") as f: + code = compile(f.read(), script, "exec") + + globals_dict = { + "__name__": "__main__", + "__file__": script, + "__package__": None, + } + + dbg.set_step() + dbg.run(code, globals_dict, {}) + + +if __name__ == "__main__": + main() diff --git a/test.py b/test.py new file mode 100644 index 0000000..481cc10 --- /dev/null +++ b/test.py @@ -0,0 +1,10 @@ +def main(): + print("hello world") + x = 50 + + for i in range(x): + print(i) + + +if __name__ == "__main__": + main() diff --git a/ui/model.go b/ui/model.go new file mode 100644 index 0000000..40d98be --- /dev/null +++ b/ui/model.go @@ -0,0 +1,57 @@ +package ui + +import ( + "strings" + + "git.pablu.de/pablu/pybug/internal/bridge" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" +) + +type Model struct { + currentFile string + cursor int + text string + width int + height int + lineCount int + bridge *bridge.Bridge + + listenBridge chan string + + messages []string + viewport viewport.Model + + breakpoints map[string][]int +} + +func NewModel(b *bridge.Bridge, file string, text string) Model { + c := b.Subscribe() + + vp := viewport.New(0, 0) + + return Model{ + currentFile: file, + text: text, + cursor: 0, + lineCount: len(strings.Split(text, "\n")), + bridge: b, + breakpoints: make(map[string][]int), + listenBridge: c, + messages: make([]string, 0), + viewport: vp, + } +} + +func ListenBridge(ch <-chan string) tea.Cmd { + return func() tea.Msg { + msg := <-ch + return StdoutMsg(msg) + } +} + +type StdoutMsg string + +func (m Model) Init() tea.Cmd { + return ListenBridge(m.listenBridge) +} diff --git a/ui/program.go b/ui/program.go new file mode 100644 index 0000000..8bbf9aa --- /dev/null +++ b/ui/program.go @@ -0,0 +1,21 @@ +package ui + +import ( + "os" + + "git.pablu.de/pablu/pybug/internal/bridge" + tea "github.com/charmbracelet/bubbletea" +) + +func Run(bridge *bridge.Bridge) error { + buf, err := os.ReadFile("test.py") + if err != nil { + return err + } + + m := NewModel(bridge, "test.py", string(buf)) + + p := tea.NewProgram(m, tea.WithAltScreen()) + _, err = p.Run() + return err +} diff --git a/ui/update.go b/ui/update.go new file mode 100644 index 0000000..c400aa1 --- /dev/null +++ b/ui/update.go @@ -0,0 +1,50 @@ +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.HandleKeyMsg(msg) + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case StdoutMsg: + m.messages = append(m.messages, string(msg)) + return m, ListenBridge(m.listenBridge) + } + + return m, nil +} + +func (m Model) HandleKeyMsg(key tea.KeyMsg) (tea.Model, tea.Cmd) { + switch key.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "k": + m.cursor = clamp(0, m.height, m.cursor-1) + case "j": + m.cursor = clamp(0, min(m.height, m.lineCount-1), m.cursor+1) + case "b": + m.bridge.Breakpoint(m.currentFile, m.cursor) + if file, ok := m.breakpoints[m.currentFile]; ok { + m.breakpoints[m.currentFile] = append(file, m.cursor+1) + } else { + m.breakpoints[m.currentFile] = []int{ + m.cursor + 1, + } + } + case "c": + m.bridge.Continue() + } + + return m, nil +} + +func clamp(minimum, maximum, val int) int { + val = max(minimum, val) + val = min(maximum, val) + return val +} diff --git a/ui/view.go b/ui/view.go new file mode 100644 index 0000000..e318356 --- /dev/null +++ b/ui/view.go @@ -0,0 +1,56 @@ +package ui + +import ( + "bytes" + "fmt" + "slices" + "strings" + + "github.com/alecthomas/chroma/v2/formatters" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + "github.com/charmbracelet/lipgloss" +) + +var panelStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + +func (m Model) View() string { + var buf bytes.Buffer + lexer := lexers.Get("python") + style := styles.Get("monokai") + formatter := formatters.Get("terminal16m") + + iterator, _ := lexer.Tokenise(nil, m.text) + formatter.Format(&buf, style, iterator) + + var out strings.Builder + + lines := strings.Split(buf.String(), "\n") + breakpoints := m.breakpoints[m.currentFile] + + for i, line := range lines { + breakpoint := " " + cursor := " " + + if slices.Contains(breakpoints, i+1) { + breakpoint = "O" + } + + if i == m.cursor { + cursor = ">" + } + fmt.Fprintf(&out, "%-4d%s%s %s\n", i+1, breakpoint, cursor, line) + } + + frameW, frameH := panelStyle.GetFrameSize() + topHeight := m.height * 70 / 100 + + code := panelStyle.Width(m.width - frameW).Height(topHeight - frameH).Render(out.String()) + m.viewport.Height = m.height - (topHeight + frameH) + m.viewport.Width = m.width - frameW + m.viewport.SetContent(strings.Join(m.messages, "")) + m.viewport.GotoBottom() + output := panelStyle.Width(m.viewport.Width).Height(m.viewport.Height).Render(m.viewport.View()) + + return lipgloss.JoinVertical(lipgloss.Top, code, output) +}