diff --git a/go.mod b/go.mod index b21d1ac..e3d165b 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,19 @@ require ( github.com/stretchr/testify v1.11.1 ) +// Dawgrun requirements +require ( + github.com/alecthomas/chroma/v2 v2.23.1 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/davecgh/go-spew v1.1.1 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 + github.com/jedib0t/go-pretty/v6 v6.7.8 + github.com/kanmu/go-sqlfmt v0.0.2-0.20200215095417-d1e63e2ee5eb + github.com/mitchellh/go-wordwrap v1.0.1 + github.com/specterops/go-repl v1.0.1 + golang.org/x/term v0.39.0 +) + require ( 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect 4d63.com/gochecknoglobals v0.2.2 // indirect @@ -37,7 +50,6 @@ require ( github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/MirrexOne/unqueryvet v1.5.4 // indirect github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect - github.com/alecthomas/chroma/v2 v2.23.1 // indirect github.com/alecthomas/go-check-sumtype v0.3.1 // indirect github.com/alexkohler/nakedret/v2 v2.0.6 // indirect github.com/alexkohler/prealloc v1.1.0 // indirect @@ -72,7 +84,6 @@ require ( github.com/ccojocar/zxcvbn-go v1.0.4 // indirect github.com/charithe/durationcheck v0.0.11 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect @@ -81,7 +92,6 @@ require ( github.com/curioswitch/go-reassign v0.3.0 // indirect github.com/daixiang0/gci v0.13.7 // indirect github.com/dave/dst v0.27.3 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect @@ -175,6 +185,7 @@ require ( github.com/nunnatsa/ginkgolinter v0.23.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.12.1 // indirect github.com/prometheus/client_model v0.2.0 // indirect @@ -247,4 +258,7 @@ require ( mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect ) -tool github.com/golangci/golangci-lint/v2/cmd/golangci-lint +tool ( + github.com/golangci/golangci-lint/v2/cmd/golangci-lint + github.com/specterops/dawgs/tools/dawgrun/cmd/dawgrun +) diff --git a/go.sum b/go.sum index 96b8e31..ff24078 100644 --- a/go.sum +++ b/go.sum @@ -363,6 +363,8 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= @@ -454,6 +456,8 @@ github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dv github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc8sr5o= +github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/jgautheron/goconst v1.8.2 h1:y0XF7X8CikZ93fSNT6WBTb/NElBu9IjaY7CCYQrCMX4= github.com/jgautheron/goconst v1.8.2/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako= github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs= @@ -473,6 +477,8 @@ github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY= github.com/kamstrup/intmap v0.5.2 h1:qnwBm1mh4XAnW9W9Ue9tZtTff8pS6+s6iKF6JRIV2Dk= github.com/kamstrup/intmap v0.5.2/go.mod h1:gWUVWHKzWj8xpJVFf5GC0O26bWmv3GqdnIX/LMT6Aq4= +github.com/kanmu/go-sqlfmt v0.0.2-0.20200215095417-d1e63e2ee5eb h1:Ztn62UtaXoFlHJIrH0AuNQrMhE355paIqZn3ik6bHNk= +github.com/kanmu/go-sqlfmt v0.0.2-0.20200215095417-d1e63e2ee5eb/go.mod h1:1+jKeqi65LLLs1GSNFRn4G/dkgg3TWeT6DTZLLQP2eM= github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0= github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY= github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw= @@ -601,6 +607,7 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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= @@ -683,6 +690,8 @@ github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs= github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas= github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0= github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs= +github.com/specterops/go-repl v1.0.1 h1:+rHsCkC8TplTr3qZCitA64xSpBXLF7hqqH5nTa8qnMQ= +github.com/specterops/go-repl v1.0.1/go.mod h1:sv0wjdiFEM2QljRCyIRZN3rZ58fa2IzMwLFGTSu8lDY= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= @@ -992,6 +1001,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/tools/dawgrun/.gitignore b/tools/dawgrun/.gitignore new file mode 100644 index 0000000..6d7d302 --- /dev/null +++ b/tools/dawgrun/.gitignore @@ -0,0 +1,2 @@ +.build.env +dawgrun diff --git a/tools/dawgrun/README.md b/tools/dawgrun/README.md new file mode 100644 index 0000000..df683bf --- /dev/null +++ b/tools/dawgrun/README.md @@ -0,0 +1,193 @@ +``` + .--~~,__ + :-....,-------`~~'._.' + `-,,, ,_ ;'~U' + _,-' ,'`-__; '--. + (_/'~~ ''''(; + + ,---. ,-.-. _,---. + _,..---._ .--.' \ ,-..-.-./ \==\ _.='.'-, \ +/==/, - \ \==\-/\ \ |, \=/\=|- |==|/==.'- / +|==| _ _\/==/-|_\ ||- |/ |/ , /==/==/ - .-' +|==| .=. |\==\, - \\, , _|==|==|_ /_,-. +|==|,| | -|/==/ - ,|| - - , |==|==| , \_.' ) +|==| '=' /==/- /\ - \\ , - /==/\==\- , ( +|==|-, _`/\==\ _.\=\.-'|- /\ /==/ /==/ _ , / +`-.`.____.' `--` `--` `--` `--`------' + .-._ + .-.,.---. .--.-. .-.-./==/ \ .-._ + /==/ ` \/==/ -|/=/ ||==|, \/ /, / + |==|-, .=., |==| ,||=| -||==|- \| | + |==| '=' /==|- | =/ ||==| , | -| + |==|- , .'|==|, \/ - ||==| - _ | + |==|_ . ,'.|==|- , /|==| /\ , | + /==/ /\ , )==/ , _ .' /==/, | |- | + `--`-`--`--'`--`..---' `--`./ `--` +``` + +`dawgrun` is a work-in-progress developer tool for interacting with +`DAWGS` and the data structures it produces. + +It currently runs as a REPL for introspecting a `DAWGS`-compatible +Postgres graph, parsing and translating Cypher queries, and executing +queries against a live connection. + +## Building + +From a `DAWGS` checkout: + + go tool dawgrun + +With a customized `DAWGS` clone, for testing features, version differences, etc: + + cd tools/dawgrun + just build-with-dawgs path/to/DAWGS + +To switch the build back to mainline: + + cd tools/dawgrun + just build-with-upstream + +## Running + +You are dropped into a prompt: + + dawgrun > + +At any time, run `help` to list commands or `help ` for +detailed usage, flag defaults, and description. + +## Commands + +The REPL supports command-name completion with `Tab`; ambiguous matches render a transient popover list near the prompt and can be dismissed with `Esc`. + +Available commands: + +``` + exit Quit + explain-psql Explains a translated query over an active PG connection + help This help message, but also more detailed help for individual commands + load-db-kinds Loads/shows the kind mapping from the specified DB into the 'active set' + lookup-kind Looks up a kind from database based on kind name + lookup-kind-id Looks up a kind from database based on kind ID + open-pg-db Connects to a specified DAWGS-compatible Postgres DB to do graph introspection. + parse Parses and dumps a Cypher query to AST form. + query-cypher Executes a Cypher query and renders table or JSON output + quit Quit + runtime-trace Manage runtime tracing + translate-psql Parses a query and converts it to the underlying PostgreSQL query +``` + +### Connections and kind maps + +Most commands that touch a database take a connection _name_ as their +first argument. Names are assigned when you open the connection and +are reused for the remainder of the session. The bottom-right status +widget shows the current number of open connections. + +A "kind map" is the mapping between a graph's kind names (e.g. +`User`, `Group`) and the numeric IDs they are stored under in +Postgres. Commands that need to translate between the two +(`lookup-kind`, `lookup-kind-id`, and the `-conn` mode of +`translate-psql`) will lazily fetch a kind map from the database the +first time they need it; `load-db-kinds` forces an immediate refresh +and dumps the result. + +## Examples + +### Open a Postgres connection + + dawgrun > open-pg-db local "postgres://dawgs:dawgs@localhost:5432/dawgs?sslmode=disable" + Opened connection 'local': postgres://dawgs:dawgs@localhost:5432/dawgs?sslmode=disable + +The first argument (`local`) is the name other commands will refer +to; the second is any DAWGS-compatible Postgres connection string. + +### Inspect kinds + +Load and dump the kind mapping from a connection: + + dawgrun > load-db-kinds local + +Resolve a kind name to its numeric ID: + + dawgrun > lookup-kind local User + Kind User => 3 + +…or go the other direction: + + dawgrun > lookup-kind-id local 3 + Kind ID 3 => User + +### Parse a Cypher query to AST + + dawgrun > parse "match (n:User) where n.name = 'alice' return n" + +The REPL highlights the dumped AST as Go source. + +> **Quoting note:** the REPL splits input with shell-style rules +> (via `shlex`), so double quotes are consumed by the line parser +> before the Cypher parser ever sees them. Cypher string literals +> must use single quotes, and queries that contain them are easiest +> to pass as a single double-quoted argument, e.g. +> `"match (n) where n.name = 'alice' return n"`. + +### Translate Cypher to PostgreSQL + +Without a connection (kinds remain symbolic): + + dawgrun > translate-psql match (n:User) return n limit 10 + +With a connection, so that kind names are resolved to the IDs in the +target database: + + dawgrun > translate-psql -conn local match (n:User) return n limit 10 + +To also see the translator's internal SQL AST alongside the formatted +query: + + dawgrun > translate-psql -conn local -dump-pg-ast match (n:User) return n limit 10 + +### Ask Postgres to EXPLAIN a translated query + +Runs the translation, prepends `EXPLAIN`, and dispatches it over the +named connection: + + dawgrun > explain-psql local "match (n:User) where n.name = 'alice' return n" + +### Execute a Cypher query + +Default `table` output: + + dawgrun > query-cypher local match (n:User) return n.name, n.objectid limit 5 + +`json` output, useful for piping into other tooling: + + dawgrun > query-cypher -format json local match (n:User) return n.name, n.objectid limit 5 + +An empty result set renders as `(0 rows)` in table mode and `[]` in +JSON mode. + +### Runtime tracing + +Capture a Go runtime trace for a subsequent command or block of work. +The trace file defaults to `trace.out` in the current directory: + + dawgrun > runtime-trace start + dawgrun > query-cypher local match (n) return count(n) + dawgrun > runtime-trace stop + +Open the resulting trace with `go tool trace trace.out`. + +## History + +The REPL persists command history to +`$XDG_CONFIG_HOME/dawgrun/history.txt` (or the platform equivalent), +capped at 1000 lines, so recent commands are available via the +arrow keys across sessions. + +## Styling + +Syntax highlighting style defaults to `monokai`, but can be configured via +the `DAWGRUN_STYLE` environment variable. +Any styles in [Chroma](https://github.com/alecthomas/chroma/tree/master/styles) are available for use as a syntax highlighting style. diff --git a/tools/dawgrun/cmd/dawgrun/art.txt b/tools/dawgrun/cmd/dawgrun/art.txt new file mode 100644 index 0000000..4131ca9 --- /dev/null +++ b/tools/dawgrun/cmd/dawgrun/art.txt @@ -0,0 +1,5 @@ + .--~~,__ + :-....,-------`~~'._.' + `-,,, ,_ ;'~U' + _,-' ,'`-__; '--. + (_/'~~ ''''(; diff --git a/tools/dawgrun/cmd/dawgrun/banner.txt b/tools/dawgrun/cmd/dawgrun/banner.txt new file mode 100644 index 0000000..9a5fb3f --- /dev/null +++ b/tools/dawgrun/cmd/dawgrun/banner.txt @@ -0,0 +1,18 @@ + ,---. ,-.-. _,---. + _,..---._ .--.' \ ,-..-.-./ \==\ _.='.'-, \ +/==/, - \ \==\-/\ \ |, \=/\=|- |==|/==.'- / +|==| _ _\/==/-|_\ ||- |/ |/ , /==/==/ - .-' +|==| .=. |\==\, - \\, , _|==|==|_ /_,-. +|==|,| | -|/==/ - ,|| - - , |==|==| , \_.' ) +|==| '=' /==/- /\ - \\ , - /==/\==\- , ( +|==|-, _`/\==\ _.\=\.-'|- /\ /==/ /==/ _ , / +`-.`.____.' `--` `--` `--` `--`------' + .-._ + .-.,.---. .--.-. .-.-./==/ \ .-._ + /==/ ` \/==/ -|/=/ ||==|, \/ /, / + |==|-, .=., |==| ,||=| -||==|- \| | + |==| '=' /==|- | =/ ||==| , | -| + |==|- , .'|==|, \/ - ||==| - _ | + |==|_ . ,'.|==|- , /|==| /\ , | + /==/ /\ , )==/ , _ .' /==/, | |- | + `--`-`--`--'`--`..---' `--`./ `--` diff --git a/tools/dawgrun/cmd/dawgrun/main.go b/tools/dawgrun/cmd/dawgrun/main.go new file mode 100644 index 0000000..980ba15 --- /dev/null +++ b/tools/dawgrun/cmd/dawgrun/main.go @@ -0,0 +1,185 @@ +package main + +import ( + "context" + _ "embed" + "fmt" + "log/slog" + "os" + "path" + "runtime/trace" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/google/shlex" + repl "github.com/specterops/go-repl" + + "github.com/specterops/dawgs/tools/dawgrun/pkg/commands" +) + +var ( + //go:embed art.txt + art string + + //go:embed banner.txt + banner string + + bannerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#87dc70")). + Background(lipgloss.Color("#533d25")) +) + +func main() { + fmt.Printf("\n%s\n%s", + art, + bannerStyle.Render(banner), + ) + fmt.Printf("\n\n\n") + fmt.Printf(" ::: DAWGRUN REPL ::: Type 'help' for more info\n") + + userConfigDir, err := os.UserConfigDir() + if err != nil { + panic(fmt.Errorf("could not get user config dir: %w", err)) + } + + // TODO: Someday, also load a config file from here for highlighting color scheme, colors enablement, etc + appConfigBaseDir := path.Join(userConfigDir, "dawgrun") + if err := os.MkdirAll(appConfigBaseDir, 0o750); err != nil { + panic(fmt.Errorf("could not create dawgrun config dir: %w", err)) + } + + handler := new(handler) + handler.cmdScope = commands.NewScope() + handler.r = repl.NewRepl(handler, &repl.Options{ + HistoryFilePath: path.Join(appConfigBaseDir, "history.txt"), + HistoryMaxLines: 1000, + StatusWidgets: &repl.StatusWidgetFns{ + Right: makeConnectionsStatusWidget(handler.cmdScope), + }, + }) + + if err := handler.r.Loop(); err != nil { + slog.Error("repl encountered error", slog.String("error", err.Error())) + } +} + +var ( + _ repl.Handler = (*handler)(nil) + _ repl.Completer = (*handler)(nil) +) + +type handler struct { + r *repl.Repl + cmdScope *commands.Scope +} + +func (h *handler) Prompt() string { + return "dawgrun > " +} + +func (h *handler) Tab(buffer string) string { + return "" +} + +func (h *handler) Complete(buffer string) repl.Completion { + prefix, ok := commandCompletionPrefix(buffer) + if !ok { + return repl.Completion{} + } + + prefix = strings.ToLower(prefix) + commandNames := commands.SortedCommandNames() + matches := make([]string, 0, len(commandNames)) + + for _, commandName := range commandNames { + if strings.HasPrefix(commandName, prefix) { + matches = append(matches, commandName) + } + } + + if len(matches) == 0 { + return repl.Completion{} + } + + if len(matches) == 1 { + match := matches[0] + if match == prefix { + return repl.Completion{Insert: " "} + } + + return repl.Completion{Insert: match[len(prefix):] + " "} + } + + return repl.Completion{ + Message: "Commands:", + Candidates: matches, + } +} + +func commandCompletionPrefix(buffer string) (string, bool) { + trimmed := strings.TrimLeft(buffer, " \t") + if trimmed == "" { + return "", true + } + + if strings.HasSuffix(trimmed, " ") || strings.HasSuffix(trimmed, "\t") { + return "", false + } + + fields := strings.Fields(trimmed) + if len(fields) != 1 { + return "", false + } + + return fields[0], true +} + +func (h *handler) Eval(line string) string { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + fields, err := shlex.Split(line) + if err != nil { + return fmt.Sprintf("Unparseable command: '%s': %s", line, err) + } + + if len(fields) == 0 { + return "Woof!" + } + + command := strings.ToLower(fields[0]) + rest := fields[1:] + if cmd, ok := commands.Registry()[command]; ok { + cmdCtx := commands.NewCommandContext(ctx, h.r, h.cmdScope) + defer trace.StartRegion(cmdCtx, fmt.Sprintf("command-%s", command)).End() + err := cmd.Fn(cmdCtx, rest) + // Reset command's associated flags after exec + defer func() { + if cmd.ClearFlagsFn != nil { + cmd.ClearFlagsFn() + } + }() + if err != nil { + return fmt.Sprintf("%s failed: %v", command, err) + } + + out := cmdCtx.OutputString() + if !strings.HasSuffix(out, "\n") { + return out + "\n" + } else { + return out + } + } + + return fmt.Sprintf("Unknown command %s; try `help`?", command) +} + +func makeConnectionsStatusWidget(cmdScope *commands.Scope) repl.StatusWidgetFn { + return func(r *repl.Repl) string { + if numConns := cmdScope.GetNumConnections(); numConns == 0 { + return "No connections" + } else { + return fmt.Sprintf("%d connection(s)", numConns) + } + } +} diff --git a/tools/dawgrun/docs/RFC.md b/tools/dawgrun/docs/RFC.md new file mode 100644 index 0000000..64f0374 --- /dev/null +++ b/tools/dawgrun/docs/RFC.md @@ -0,0 +1,219 @@ +--- +bh-rfc: unlisted +title: "dawgrun: Playground for DAWGS" +authors: | + [Sean Johnson](sjohnson@specterops.io) +status: DRAFT +created: 2026-01-29 +audiences: | + DAWGS maintainers and contributors + Engineers in adjacent repositories who author or debug DAWGS-backed queries +--- + +# dawgrun: Playground for DAWGS + +## 1. Overview + +`dawgrun` is a developer tool for exploring, debugging, and validating DAWGS behavior from a single interactive surface. + +Today, `dawgrun` operates as a REPL focused on PostgreSQL-backed DAWGS workflows. It supports opening DAWGS-compatible database connections, inspecting kind mappings, parsing CySQL/Cypher queries, translating those queries to PostgreSQL, running translated query plans with `EXPLAIN`, and executing Cypher directly. + +This RFC formalizes the current state of the tool and proposes an incremental roadmap that keeps `dawgrun` intentionally broad in mission: a long-lived playground for DAWGS debugging and experimentation. + +## 2. Motivation & Goals + +DAWGS development currently depends on a mix of tests, ad hoc scripts, and one-off debugging approaches. Those methods are useful but fragmented, and they do not provide a single place to inspect parser output, translation behavior, backend kind mapping, and live query execution together. + +`dawgrun` is meant to close that gap by providing a practical, team-owned workflow tool that can be used while authoring features, investigating regressions, and workshopping query behavior across repositories that depend on DAWGS. + +- **Unify Debugging Workflows** - Provide one tool that centralizes query parsing, translation inspection, kind resolution, and execution against live backends. +- **Shorten Iteration Loops** - Reduce friction between changing DAWGS code and validating behavior against real-world query/data scenarios. +- **Improve Translation Visibility** - Make AST and generated SQL output easy to inspect, compare, and discuss during development. +- **Support Real Backends** - Enable debugging against actual DAWGS-compatible data sources rather than only synthetic unit-test fixtures. +- **Remain Extensible** - Preserve room for deeper introspection capabilities as DAWGS evolves. + +## 3. Considerations + +### 3.1 Impact on Existing Systems + +`dawgrun` is an additive developer tool and does not change runtime behavior of DAWGS libraries in production paths. + +The tool integrates at DAWGS API boundaries and should continue to do so. New features SHOULD prefer composition over invasive changes to core DAWGS packages unless a clear library-level improvement is justified independently. + +### 3.2 Security & Compliance + +`dawgrun` targets engineering and test workflows, but it still operates on live data connections. Usage SHOULD assume standard safeguards for credentials, access controls, and environment boundaries. + +At minimum: + +- Connection strings MUST be handled as sensitive values. +- Query output MAY include sensitive graph attributes and SHOULD be used accordingly. +- Features that increase automation or scripting SHOULD avoid encouraging unsafe credential handling patterns. + +### 3.3 Drawbacks & Alternatives + +`dawgrun` currently uses a modified fork of `github.com/openengineer/go-repl` and carries that maintenance burden in-tree. This provides needed control, but it also means REPL behavior is partly owned by this project. + +Primary trade-offs: + +- A REPL-first interface is efficient for iterative debugging, but less direct for batch automation. +- A broad mission can drift into unstructured feature sprawl without explicit scope guardrails. +- Maintaining tooling inside this repository creates ongoing upkeep cost. + +Alternatives include continuing with ad hoc scripts or embedding isolated diagnostics in each dependent repository. Those options lower central tool complexity, but they duplicate effort and weaken shared debugging conventions. + +### 3.4 Audience + +The primary audience is engineers actively developing DAWGS. + +Secondary audiences include engineers in BHCE/BHE and other adjacent codebases who need to workshop or debug DAWGS-backed query behavior before committing changes to application-level implementations. + +### 3.5 Adoption Model + +`dawgrun` is an as-needed engineering tool, not a formal user-facing product feature. + +Adoption is intentionally lightweight: + +- No coordinated organization-wide rollout is required. +- Capabilities are expected to evolve incrementally as development needs arise. +- Changes SHOULD still be documented clearly so workflows remain discoverable for current and future contributors. + +## 4. Current State + +This section documents the implemented baseline at the time of writing. + +### 4.1 Runtime Model + +`dawgrun` currently runs as an interactive REPL with command parsing, persistent session history, and command-name completion. + +- Prompt: `dawgrun >` +- History file: `$XDG_CONFIG_HOME/dawgrun/history.txt` (or platform equivalent) +- Status widget: active connection count +- Completion: command-name matching with candidate popover + +### 4.2 Implemented Command Surface + +The current command set includes: + +- `open-pg-db` - open a named DAWGS-compatible PostgreSQL connection +- `load-db-kinds` - refresh and print kind mappings for a connection +- `lookup-kind` / `lookup-kind-id` - resolve kind names and IDs +- `parse` - parse CySQL/Cypher into AST and dump highlighted output +- `translate-psql` - translate CySQL/Cypher to PostgreSQL SQL +- `explain-psql` - translate, prepend `EXPLAIN`, and execute against PostgreSQL +- `query-cypher` - execute query over active connection with table/JSON output +- `runtime-trace` - start/stop Go runtime tracing to a file +- `help`, `exit`, `quit` + +### 4.3 Query and Translation Workflows + +Current query workflows support: + +- Parsing query input into Cypher AST structures for inspection. +- Translating Cypher into PostgreSQL statements. +- Optional dump of translator SQL AST (`translate-psql -dump-pg-ast`). +- Optional kind-aware translation against a named connection (`translate-psql -conn `). +- `EXPLAIN` execution against translated SQL for planner visibility. + +### 4.4 Data and Kind Inspection + +Named connections and lazily loaded kind maps provide the basis for cross-command introspection. + +- Kind maps are fetched from the live backend and cached in command scope. +- Name/ID lookup helpers support debugging mismatches in query translation and execution. +- Current backend connection command is PostgreSQL-specific. + +### 4.5 Known Constraints + +- Backend connection management is PostgreSQL-specific (`open-pg-db`); broader driver support is not yet implemented. +- REPL input uses shell-style tokenization, which affects quoting behavior for Cypher string literals. +- The tool is interactive-first; scriptability exists only in limited form through command composition and shell invocation patterns. + +## 5. Details of the Proposal + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt). + +This proposal keeps the broad long-term goal intact while defining concrete near-term improvements. + +### 5.1 Data Inspection + +`dawgrun` MUST continue to support interactive inspection against live graph data and kind metadata. + +Requirements: + +- The tool MUST preserve straightforward connection management for at least PostgreSQL. +- The tool SHOULD provide a path to generic `open-db` behavior for additional DAWGS drivers. +- Kind inspection SHOULD remain available as first-class commands and SHOULD support both name-to-ID and ID-to-name workflows. +- Refresh semantics for kind mappings MUST remain explicit and predictable. + +Implementation direction: + +- Evolve command surface from backend-specific naming toward backend-agnostic patterns where practical. +- Maintain compatibility with existing `open-pg-db` usage during migration. + +### 5.2 Query Tooling + +`dawgrun` MUST provide robust tools for understanding query lifecycle transformations. + +Requirements: + +- Users MUST be able to parse and inspect CySQL/Cypher AST output. +- Users MUST be able to translate CySQL/Cypher into PostgreSQL queries with and without live kind mapping. +- Users SHOULD be able to inspect both final SQL and intermediate translator AST artifacts. +- Users SHOULD be able to validate planner behavior through `EXPLAIN` on translated output. +- Query execution output MUST remain consumable both by humans (table mode) and tooling (JSON mode). + +Implementation direction: + +- Preserve current `parse`, `translate-psql`, `explain-psql`, and `query-cypher` workflows as the stable baseline. +- Add workflow shortcuts only when they map to repeatable debugging use-cases and do not obscure underlying behavior. + +### 5.3 Developer Tooling + +`dawgrun` SHOULD continue evolving into a generalized DAWGS debugging workbench. + +Target capabilities: + +- Support manually stepping query translation paths for deeper debugging visibility. +- Support interactive construction and execution of DAWGS graph-query components. +- Support generation helpers for translation test-case authoring. +- Improve automation friendliness for repeated local workflows (for example, repeatable startup state and command scripts). + +Guardrails for expansion: + +- New capabilities MUST correspond to concrete DAWGS engineering workflows. +- REPL ergonomics and clarity SHOULD be favored over highly specialized one-off behavior. +- Additions SHOULD document whether they are implemented, experimental, or roadmap-only. + +## 6. Scope Boundaries + +`dawgrun` is intentionally broad in mission, but not unbounded in purpose. + +In scope: + +- Any developer-facing capability that materially improves DAWGS debugging, query workshopping, or translation validation. +- Features that help engineers observe behavior across parser, translator, kind mapping, and backend execution layers. + +Out of scope: + +- End-user product UX concerns unrelated to DAWGS engineering workflows. +- Replacing formal test suites, CI validation, or benchmark infrastructure. +- Adding features that do not map to recurring debugging or development needs. + +## 7. Success Criteria + +This RFC is successful when `dawgrun` is treated as the default developer entry point for interactive DAWGS debugging by its primary audience. + +Practical indicators include: + +- Engineers can move from query idea to parser/translator/execute feedback in a single session. +- Kind mapping and translation issues can be reproduced and inspected without custom one-off scripts. +- New DAWGS contributors can discover and use common debugging workflows through command help and documentation. +- Expansion work remains coherent with the tool's mission rather than fragmenting into unrelated utilities. + +## 8. Open Questions + +- What is the preferred migration path from `open-pg-db` to a generic multi-driver connection interface? +- Which automation model is most valuable first: startup config profiles, scripted command batches, or both? +- What level of translation-step introspection is useful by default versus too noisy for routine workflows? +- Should roadmap features be tagged by maturity level directly in help output to clarify supported versus experimental behavior? diff --git a/tools/dawgrun/pkg/commands/cypher.go b/tools/dawgrun/pkg/commands/cypher.go new file mode 100644 index 0000000..0560f97 --- /dev/null +++ b/tools/dawgrun/pkg/commands/cypher.go @@ -0,0 +1,490 @@ +package commands + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "strings" + + "github.com/davecgh/go-spew/spew" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" + "github.com/kanmu/go-sqlfmt/sqlfmt" + "github.com/specterops/dawgs/cypher/models/pgsql/format" + "github.com/specterops/dawgs/cypher/models/pgsql/translate" + "github.com/specterops/dawgs/graph" + "golang.org/x/term" + + "github.com/specterops/dawgs/tools/dawgrun/pkg/stubs" +) + +const ( + queryCypherOutputFormatTable = "table" + queryCypherOutputFormatJSON = "json" +) + +func parseCmd() CommandDesc { + return CommandDesc{ + args: []string{"<...query>"}, + help: "Parses and dumps a Cypher query to AST form.", + + Fn: func(ctx *CommandContext, fields []string) error { + query, err := parseQueryArray(fields) + if err != nil { + return fmt.Errorf("error trying to parse query '%s': %w", fields, err) + } + + ctx.output.WriteHighlighted(spew.Sdump(query), "golang") + return nil + }, + } +} + +func translateToPsqlCmd() CommandDesc { + flagSet := flag.NewFlagSet("translate-psql", flag.ContinueOnError) + + var ( + kindMapperConnRef = "" + dumpTranslatedAst = false + ) + + flagSet.StringVar(&kindMapperConnRef, "conn", "", "Connection reference for choosing a kind mapper") + flagSet.BoolVar(&dumpTranslatedAst, "dump-pg-ast", false, "Whether to dump the translator's constructed AST") + + return CommandDesc{ + args: []string{"[flags]", "<...query>"}, + help: "Parses a query and converts it to the underlying PostgreSQL query", + desc: "Does a bunch of magic to fully translate a Cypher query into a PostgreSQL query", + flags: flagSet, + + ClearFlagsFn: func() { + kindMapperConnRef = "" + dumpTranslatedAst = false + }, + Fn: func(ctx *CommandContext, fields []string) error { + if err := flagSet.Parse(fields); err != nil { + return fmt.Errorf("could not parse flags: %w", err) + } + + fields = flagSet.Args() + query, err := parseQueryArray(fields) + if err != nil { + return fmt.Errorf("error trying to parse query '%s': %w", fields, err) + } + + kindMapper := stubs.EmptyMapper() + if kindMapperConnRef != "" { + // Fetch kinds regardless of if it's already loaded. + kindMap, err := loadKindMap(ctx, kindMapperConnRef) + if err != nil { + return fmt.Errorf("could not load kind map for explain: %w", err) + } + kindMapper = stubs.MapperFromKindMap(kindMap) + } + + result, err := translate.Translate(ctx, query, kindMapper, nil) + if err != nil { + return fmt.Errorf("could not translate cypher query to pgsql: %w", err) + } + if dumpTranslatedAst { + fmt.Fprintf(ctx.output, "TRANSLATOR AST\n\n") + ctx.output.WriteHighlighted(spew.Sdump(result.Statement), "golang") + fmt.Fprintf(ctx.output, "\n") + } + + // Certain queries will materialize parameters into the output when translated, so we need to build + // an OutputBuilder so we can carry forward those params. + queryBuilder := format.NewOutputBuilder() + if result.Parameters != nil { + queryBuilder.WithMaterializedParameters(result.Parameters) + } + + sqlQuery, err := format.Statement(result.Statement, queryBuilder) + if err != nil { + return fmt.Errorf("could not format translated statement into a string query: %w", err) + } + + formattedQuery, err := sqlfmt.Format(sqlQuery, &sqlfmt.Options{ + Distance: 0, + }) + if err != nil { + ctx.output.Warnf("could not format query: %s", err.Error()) + formattedQuery = sqlQuery + } + + ctx.output.WriteHighlighted(formattedQuery, "postgres") + return nil + }, + } +} + +func explainAsPsqlCmd() CommandDesc { + return CommandDesc{ + args: []string{"", "<...query>"}, + help: "Explains a translated query over an active PG connection", + desc: "Asks the PG query planner to explain the (translated) Cypher query in PG terms", + + Fn: func(ctx *CommandContext, fields []string) error { + if len(fields) < 2 { + return fmt.Errorf("invalid usage, requires: ") + } + + connName := fields[0] + conn, ok := ctx.scope.connections[connName] + if !ok { + return fmt.Errorf("connection %s not found; did you `open` it?", connName) + } + + // Fetch kinds regardless of if it's already loaded. + kindMap, err := loadKindMap(ctx, connName) + if err != nil { + return fmt.Errorf("could not load kind map for explain: %w", err) + } + + query, err := parseQueryArray(fields[1:]) + if err != nil { + return fmt.Errorf("could not parse query: %w", err) + } + + // Populate a DumbKindMapper from the database's kinds table + kindMapper := stubs.MapperFromKindMap(kindMap) + result, err := translate.Translate(ctx, query, kindMapper, nil) + if err != nil { + return fmt.Errorf("could not translate cypher query to pgsql: %w", err) + } + + // Certain queries will materialize parameters into the output when translated, so we need to build + // an OutputBuilder so we can carry forward those params. + queryBuilder := format.NewOutputBuilder() + if result.Parameters != nil { + queryBuilder.WithMaterializedParameters(result.Parameters) + } + + sqlQuery, err := format.Statement(result.Statement, queryBuilder) + if err != nil { + return fmt.Errorf("could not format translated statement into a string query: %w", err) + } + + formattedQuery, err := sqlfmt.Format(sqlQuery, &sqlfmt.Options{ + Distance: 2, + }) + if err != nil { + ctx.output.Warnf("could not format query: %s", err.Error()) + formattedQuery = sqlQuery + } + explainSQLQuery := fmt.Sprintf("EXPLAIN %s", formattedQuery) + ctx.output.WriteHighlighted(explainSQLQuery, "postgres") + fmt.Fprint(ctx.output, "\n\n") + + err = conn.ReadTransaction(ctx, func(tx graph.Transaction) error { + result := tx.Raw(explainSQLQuery, nil) + if err := result.Error(); err != nil { + return fmt.Errorf("error running raw query: '%s': %w", explainSQLQuery, err) + } + defer result.Close() + + var value string + for result.Next() { + if err := graph.ScanNextResult(result, &value); err != nil { + return fmt.Errorf("could not scan EXPLAIN row: %w", err) + } + fmt.Fprintf(ctx.output, " %s\n", value) + } + + return nil + }) + if err != nil { + return fmt.Errorf("could not run EXPLAIN query: %w", err) + } + + return nil + }, + } +} + +func queryCypherCmd() CommandDesc { + flagSet := flag.NewFlagSet("query-cypher", flag.ContinueOnError) + + outputFormat := queryCypherOutputFormatTable + flagSet.StringVar(&outputFormat, "format", queryCypherOutputFormatTable, "Output format: table or json") + + return CommandDesc{ + args: []string{"[flags]", "", "<...query>"}, + help: "Executes a Cypher query and renders table or JSON output", + desc: "Runs a Cypher query over an active backend connection and prints fetched rows", + flags: flagSet, + + ClearFlagsFn: func() { + outputFormat = queryCypherOutputFormatTable + }, + Fn: func(ctx *CommandContext, fields []string) error { + outputFormat = queryCypherOutputFormatTable + if err := flagSet.Parse(fields); err != nil { + return fmt.Errorf("could not parse flags: %w", err) + } + + fields = flagSet.Args() + if len(fields) < 2 { + return fmt.Errorf("invalid usage, requires: ") + } + + outputFormat = strings.ToLower(strings.TrimSpace(outputFormat)) + switch outputFormat { + case queryCypherOutputFormatTable, queryCypherOutputFormatJSON: + default: + return fmt.Errorf("invalid output format %q; expected one of: %s, %s", outputFormat, queryCypherOutputFormatTable, queryCypherOutputFormatJSON) + } + + connName := fields[0] + conn, ok := ctx.scope.connections[connName] + if !ok { + return fmt.Errorf("connection %s not found; did you `open` it?", connName) + } + + cypherQuery := strings.Join(fields[1:], " ") + + return conn.ReadTransaction(ctx, func(tx graph.Transaction) error { + result := tx.Query(cypherQuery, nil) + if err := result.Error(); err != nil { + return fmt.Errorf("error running cypher query '%s': %w", cypherQuery, err) + } + defer result.Close() + + switch outputFormat { + case queryCypherOutputFormatJSON: + return queryCypherOutputJSON(ctx, result) + case queryCypherOutputFormatTable: + return queryCypherOutputTable(ctx, result) + } + + return fmt.Errorf("unknown output format: %s", outputFormat) + }) + }, + } +} + +func queryCypherOutputJSON(ctx *CommandContext, result graph.Result) error { + var outputColumns []string + + fmt.Fprint(ctx.output, "[\n") + + rowCount := 0 + for result.Next() { + values := result.Values() + insertFormat := ",\n %s" + + if rowCount == 0 { + outputColumns = buildCypherResultColumns(result.Keys(), len(values)) + insertFormat = " %s" + } + + rowOutput, err := json.MarshalIndent(buildCypherResultJSONRow(outputColumns, values), " ", " ") + if err != nil { + return fmt.Errorf("error marshalling JSON row: %v: %w", values, err) + } + + fmt.Fprintf(ctx.output, insertFormat, rowOutput) + rowCount++ + } + + if err := result.Error(); err != nil { + return fmt.Errorf("error fetching query rows: %w", err) + } + + fmt.Fprint(ctx.output, "\n]\n") + + return nil +} + +func queryCypherOutputTable(ctx *CommandContext, result graph.Result) error { + var ( + outputColumns []string + outputTable table.Writer + ) + + outputTable = table.NewWriter() + style := table.StyleRounded + style.Options.SeparateRows = true + style.Size.WidthMax = cypherResultTableWidth() + outputTable.SetStyle(style) + + rowCount := 0 + for result.Next() { + values := result.Values() + + if rowCount == 0 { + outputColumns = buildCypherResultColumns(result.Keys(), len(values)) + + outputTable.AppendHeader(buildCypherResultHeader(outputColumns)) + outputTable.SetColumnConfigs(buildCypherResultColumnConfigs(len(outputColumns), cypherResultTableWidth())) + } + + outputTable.AppendRow(buildCypherResultRow(values)) + rowCount++ + } + + if err := result.Error(); err != nil { + return fmt.Errorf("error fetching query rows: %w", err) + } + + if rowCount == 0 { + fmt.Fprint(ctx.output, "(0 rows)\n") + return nil + } + + fmt.Fprint(ctx.output, outputTable.Render()) + fmt.Fprintf(ctx.output, "\n(%d rows)\n", rowCount) + + return nil +} + +func cypherResultTableWidth() int { + const ( + fallbackWidth = 120 + ) + + if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil { + return width - 2 + } + + return fallbackWidth +} + +func buildCypherResultColumnConfigs(columnCount, tableWidth int) []table.ColumnConfig { + if columnCount == 0 { + return nil + } + + const ( + minColumnWidth = 12 + innerPadding = 3 + ) + + availableWidth := tableWidth - 1 - (columnCount * innerPadding) + columnWidth := availableWidth / columnCount + if columnWidth < minColumnWidth { + columnWidth = minColumnWidth + } + + configs := make([]table.ColumnConfig, 0, columnCount) + for idx := 0; idx < columnCount; idx++ { + configs = append(configs, table.ColumnConfig{ + Number: idx + 1, + WidthMax: columnWidth, + WidthMaxEnforcer: text.WrapHard, + }) + } + + return configs +} + +func buildCypherResultColumns(keys []string, numValues int) []string { + columns := append([]string{}, keys...) + + if len(columns) < numValues { + for idx := len(columns); idx < numValues; idx++ { + columns = append(columns, fmt.Sprintf("column_%d", idx+1)) + } + } + + return columns +} + +func buildCypherResultHeader(columns []string) table.Row { + row := make(table.Row, len(columns)) + for idx, key := range columns { + row[idx] = key + } + + return row +} + +func buildCypherResultJSONRow(columns []string, values []any) map[string]any { + row := make(map[string]any, len(columns)) + for idx, key := range columns { + if idx < len(values) { + row[key] = formatCypherResultJSONValue(values[idx]) + } else { + row[key] = nil + } + } + + return row +} + +func formatCypherResultJSONValue(value any) any { + switch typed := value.(type) { + case nil: + return nil + case bool, + int, + int8, + int16, + int32, + int64, + uint, + uint8, + uint16, + uint32, + uint64, + float32, + float64, + string: + return typed + case graph.ID: + return typed.Uint64() + case []byte: + return string(typed) + default: + if marshaled, err := json.Marshal(typed); err == nil { + var normalized any + if err := json.Unmarshal(marshaled, &normalized); err == nil { + return normalized + } + } + + return fmt.Sprintf("%v", typed) + } +} + +func buildCypherResultRow(values []any) table.Row { + row := make(table.Row, len(values)) + for idx, value := range values { + row[idx] = formatCypherResultValue(value) + } + + return row +} + +func formatCypherResultValue(value any) any { + switch typed := value.(type) { + case nil: + return "" + case bool, + int, + int8, + int16, + int32, + int64, + uint, + uint8, + uint16, + uint32, + uint64, + float32, + float64, + string: + return typed + case []byte: + return string(typed) + case fmt.Stringer: + return typed.String() + default: + if marshaled, err := json.Marshal(typed); err == nil { + return string(marshaled) + } + + return fmt.Sprintf("%v", typed) + } +} diff --git a/tools/dawgrun/pkg/commands/db.go b/tools/dawgrun/pkg/commands/db.go new file mode 100644 index 0000000..3119860 --- /dev/null +++ b/tools/dawgrun/pkg/commands/db.go @@ -0,0 +1,199 @@ +package commands + +import ( + "fmt" + "strconv" + + "github.com/davecgh/go-spew/spew" + "github.com/specterops/dawgs" + "github.com/specterops/dawgs/drivers" + "github.com/specterops/dawgs/drivers/pg" + "github.com/specterops/dawgs/graph" + + "github.com/specterops/dawgs/tools/dawgrun/pkg/stubs" +) + +// TODO(seanj): Convert to generic open-db command that supports any available driver +func openPGDBCmd() CommandDesc { + return CommandDesc{ + args: []string{"", ""}, + help: "Connects to a specified DAWGS-compatible Postgres DB to do graph introspection.", + + Fn: func(ctx *CommandContext, fields []string) error { + if len(fields) < 2 { + return fmt.Errorf("invalid usage: open-pg-db ") + } + + name := fields[0] + connStr := fields[1] + connPool, err := pg.NewPool(drivers.DatabaseConfiguration{ + Connection: connStr, + }) + if err != nil { + return fmt.Errorf("error opening connection pool: %w", err) + } + + querier, err := dawgs.Open(ctx, "pg", dawgs.Config{ + ConnectionString: connStr, + Pool: connPool, + }) + if err != nil { + return fmt.Errorf("error opening database connection '%s': %w", connStr, err) + } + + if existingConn, ok := ctx.scope.connections[name]; ok { + // Warn+close existing connection before overwriting it + ctx.output.Warnf("Discarding previous connection for '%s'", name) + if err := existingConn.Close(ctx); err != nil { + return fmt.Errorf("could not close previous connection '%s' for overwriting: %w", name, err) + } + + // Wipe out handles and resources for this connection + ctx.scope.DropConnection(name) + } + + fmt.Fprintf(ctx.output, "Opened connection '%s'\n", name) + ctx.scope.AddConnection(name, querier) + + return nil + }, + } +} + +func getPGDBKinds() CommandDesc { + return CommandDesc{ + args: []string{""}, + help: "Loads/shows the kind mapping from the specified DB into the 'active set'", + + Fn: func(ctx *CommandContext, fields []string) error { + if len(fields) != 1 { + return fmt.Errorf("invalid usage: load-db-kinds ") + } + + connName := fields[0] + if kindMap, err := loadKindMap(ctx, connName); err != nil { + return fmt.Errorf("could not load kind map: %w", err) + } else { + ctx.scope.connKindMaps[connName] = kindMap + fmt.Fprintf(ctx.output, "Loaded kind map from connection '%s':\n", connName) + ctx.output.WriteHighlighted(spew.Sdump(kindMap), "golang") + } + + return nil + }, + } +} + +func loadKindMap(ctx *CommandContext, connName string) (stubs.KindMap, error) { + conn, ok := ctx.scope.connections[connName] + if !ok { + return nil, fmt.Errorf("unknown connection %s; did you `open` it?", connName) + } + + // Force a refresh from the database backend + if err := conn.RefreshKinds(ctx); err != nil { + return nil, fmt.Errorf("could not refresh kinds for connection %s: %w", connName, err) + } + + // Load kinds list + kinds, err := conn.FetchKinds(ctx) + if err != nil { + return nil, fmt.Errorf("could not fetch kinds for connection %s: %w", connName, err) + } + + // Coerce a pg.Driver out of the conn + driver, ok := conn.(*pg.Driver) + if !ok { + return nil, fmt.Errorf("connection %s is not a 'pg' connection", connName) + } + + // Map the kinds to their IDs + kindIds, err := driver.MapKinds(ctx, kinds) + if err != nil { + return nil, fmt.Errorf("could not map kinds to IDs: %s", err) + } + + kindMap := make(stubs.KindMap) + for idx, kind := range kinds { + kindMap[kindIds[idx]] = kind + } + + ctx.scope.connKindMaps[connName] = kindMap + + return kindMap, nil +} + +func lookupKindCmd() CommandDesc { + return CommandDesc{ + args: []string{"", ""}, + help: "Looks up a kind from database based on kind name", + + Fn: func(ctx *CommandContext, fields []string) error { + if len(fields) < 2 { + return fmt.Errorf("invalid usage: lookup-kind ") + } + + connName := fields[0] + kindName := fields[1] + + kindMap, ok := ctx.scope.connKindMaps[connName] + if !ok { + // Try to fetch the kind map if the connection is open + var err error + kindMap, err = loadKindMap(ctx, connName) + if err != nil { + return fmt.Errorf("could not fetch kind map: %w", err) + } + } + + mapper := stubs.MapperFromKindMap(kindMap) + if kindID, err := mapper.GetIDByKind(graph.StringKind(kindName)); err != nil { + return fmt.Errorf("could not look up kind: %w", err) + } else { + fmt.Fprintf(ctx.output, "Kind %s => %d", kindName, kindID) + } + + return nil + }, + } +} + +func lookupKindIDCmd() CommandDesc { + return CommandDesc{ + args: []string{"", ""}, + help: "Looks up a kind from database based on kind ID", + + Fn: func(ctx *CommandContext, fields []string) error { + if len(fields) < 2 { + return fmt.Errorf("invalid usage: lookup-kind-id ") + } + + connName := fields[0] + kindIDStr := fields[1] + + kindID, err := strconv.ParseInt(kindIDStr, 10, 16) + if err != nil { + return fmt.Errorf("could not parse kind ID as int: %s: %w", kindIDStr, err) + } + + kindMap, ok := ctx.scope.connKindMaps[connName] + if !ok { + // Try to fetch the kind map if the connection is open + var err error + kindMap, err = loadKindMap(ctx, connName) + if err != nil { + return fmt.Errorf("could not fetch kind map: %w", err) + } + } + + mapper := stubs.MapperFromKindMap(kindMap) + if kind, err := mapper.GetKindByID(int16(kindID)); err != nil { + return fmt.Errorf("could not look up kind: %w", err) + } else { + fmt.Fprintf(ctx.output, "Kind ID %d => %s", kindID, kind) + } + + return nil + }, + } +} diff --git a/tools/dawgrun/pkg/commands/help.go b/tools/dawgrun/pkg/commands/help.go new file mode 100644 index 0000000..7ef15a7 --- /dev/null +++ b/tools/dawgrun/pkg/commands/help.go @@ -0,0 +1,80 @@ +package commands + +import ( + "flag" + "fmt" + "maps" + "slices" + "strings" + + "github.com/mitchellh/go-wordwrap" +) + +func getFlagSetHelpDetails(flagSet *flag.FlagSet) string { + flagDefaults := new(strings.Builder) + oldOutput := flagSet.Output() + + flagSet.SetOutput(flagDefaults) + flagSet.PrintDefaults() + flagSet.SetOutput(oldOutput) + + return flagDefaults.String() +} + +func helpCmd() CommandDesc { + return CommandDesc{ + args: []string{"[command]"}, + help: "This help message, but also more detailed help for individual commands", + desc: "Get help for all commands", + + Fn: func(ctx *CommandContext, fields []string) error { + if len(fields) == 0 { + // HELP OVERVIEW + maxNameLength := 0 + for name := range cmdRegistry { + if nameLen := len(name); nameLen > maxNameLength { + maxNameLength = nameLen + } + } + + sortedCommands := slices.Sorted(maps.Keys(cmdRegistry)) + for _, name := range sortedCommands { + commandLeft := fmt.Sprintf( + "%s%s%s", + strings.Repeat(" ", 4), + name, + strings.Repeat(" ", maxNameLength-(len(name))), + ) + cmd := cmdRegistry[name] + spacerPad := strings.Repeat(" ", len(commandLeft)) + fmt.Fprintf(ctx.output, "%s%s%s\n", commandLeft, spacerPad, cmd.help) + } + } else { + // COMMAND HELP + name := fields[0] + cmd, ok := cmdRegistry[name] + if !ok { + return fmt.Errorf("unknown command: %s", name) + } + + wrappedHelp := indentLines(wordwrap.WrapString(cmd.help, 80), 1) + fmt.Fprintf(ctx.output, "\nHELP: %s %s\n\n", name, strings.Join(cmd.args, " ")) + fmt.Fprintf(ctx.output, "%s\n", wrappedHelp) + if strings.TrimSpace(cmd.desc) != "" { + fmt.Fprint(ctx.output, "\n") + fmt.Fprintf(ctx.output, "%s\n", indentLines(wordwrap.WrapString(cmd.desc, 80), 1)) + } + if cmd.flags != nil { + flagDefaults := getFlagSetHelpDetails(cmd.flags) + fmt.Fprintf(ctx.output, "\n%s\n%s\n", + indentLines("flags:", 1), + indentLines(wordwrap.WrapString(flagDefaults, 80), 2), + ) + } + fmt.Fprint(ctx.output, "END HELP\n") + } + + return nil + }, + } +} diff --git a/tools/dawgrun/pkg/commands/helpers.go b/tools/dawgrun/pkg/commands/helpers.go new file mode 100644 index 0000000..e32aa0e --- /dev/null +++ b/tools/dawgrun/pkg/commands/helpers.go @@ -0,0 +1,33 @@ +package commands + +import ( + "fmt" + "strings" + + "github.com/alecthomas/chroma/v2/quick" + cypherFrontend "github.com/specterops/dawgs/cypher/frontend" + cypherModels "github.com/specterops/dawgs/cypher/models/cypher" +) + +func parseQueryArray(fields []string) (*cypherModels.RegularQuery, error) { + cypherCtx := cypherFrontend.DefaultCypherContext() + return cypherFrontend.ParseCypher(cypherCtx, strings.Join(fields, " ")) +} + +func indentLines(text string, indentCount int) string { + builder := new(strings.Builder) + for line := range strings.Lines(text) { + fmt.Fprintf(builder, "%s%s", strings.Repeat("\t", indentCount), line) + } + + return builder.String() +} + +func highlightText(text, lexer, style string) (string, error) { + builder := new(strings.Builder) + if err := quick.Highlight(builder, text, lexer, "terminal256", style); err != nil { + return "", fmt.Errorf("could not highlight source text: %w", err) + } + + return builder.String(), nil +} diff --git a/tools/dawgrun/pkg/commands/registry.go b/tools/dawgrun/pkg/commands/registry.go new file mode 100644 index 0000000..bd21524 --- /dev/null +++ b/tools/dawgrun/pkg/commands/registry.go @@ -0,0 +1,33 @@ +// Package commands holds all of the repl commands along with infrastructure types and helpers +package commands + +import ( + "maps" + "slices" +) + +var cmdRegistry map[string]CommandDesc = map[string]CommandDesc{ + "exit": quitCmd(), + "explain-psql": explainAsPsqlCmd(), + "load-db-kinds": getPGDBKinds(), + "lookup-kind": lookupKindCmd(), + "lookup-kind-id": lookupKindIDCmd(), + "open-pg-db": openPGDBCmd(), + "parse": parseCmd(), + "query-cypher": queryCypherCmd(), + "quit": quitCmd(), + "runtime-trace": runtimeTraceCmd(), + "translate-psql": translateToPsqlCmd(), +} + +func init() { + cmdRegistry["help"] = helpCmd() +} + +func Registry() map[string]CommandDesc { + return cmdRegistry +} + +func SortedCommandNames() []string { + return slices.Sorted(maps.Keys(cmdRegistry)) +} diff --git a/tools/dawgrun/pkg/commands/runtime.go b/tools/dawgrun/pkg/commands/runtime.go new file mode 100644 index 0000000..c09ffa5 --- /dev/null +++ b/tools/dawgrun/pkg/commands/runtime.go @@ -0,0 +1,92 @@ +package commands + +import ( + "fmt" + "os" + "runtime/trace" + "strings" +) + +func quitCmd() CommandDesc { + return CommandDesc{ + args: []string{}, + help: "Quit", + desc: "Exits the REPL session", + + Fn: func(ctx *CommandContext, fields []string) error { + ctx.instance.Quit() + return nil + }, + } +} + +func runtimeTraceCmd() CommandDesc { + state := make(map[string]any) + state["run"] = false + state["tracefile"] = nil + + usage := "runtime-trace [tracefile]" + + return CommandDesc{ + args: []string{"start|stop", "[tracefile]"}, + help: "Manage runtime tracing", + desc: `start [tracefile] - Start tracing with output to [tracefile] if provided, otherwise trace.out +stop - Stop runtime tracing and close the trace file`, + + Fn: func(ctx *CommandContext, fields []string) error { + if len(fields) == 0 { + return fmt.Errorf("invalid usage: %s", usage) + } + subcmd := strings.ToLower(fields[0]) + switch subcmd { + case "start": + if running, ok := state["run"].(bool); ok && running { + return fmt.Errorf("runtime tracing is already enabled") + } + + var traceOut string + if len(fields) > 1 { + traceOut = fields[1] + } else { + traceOut = "trace.out" + } + + traceFile, err := os.Create(traceOut) + if err != nil { + return fmt.Errorf("error creating tracefile: %w", err) + } + + if err := trace.Start(traceFile); err != nil { + traceFile.Close() + return fmt.Errorf("could not start tracing: %w", err) + } + + state["run"] = true + state["tracefile"] = traceFile + + fmt.Fprintf(ctx.output, "Started runtime tracing to %s", traceOut) + return nil + case "stop": + if running, ok := state["run"].(bool); ok && !running { + return fmt.Errorf("runtime tracing is not running") + } + + trace.Stop() + traceFile, ok := state["tracefile"].(*os.File) + if !ok { + return fmt.Errorf("could not get open tracing file") + } + + traceFile.Close() + + state["run"] = false + state["tracefile"] = nil + + fmt.Fprint(ctx.output, "Stopped runtime tracing") + return nil + default: + return fmt.Errorf("invalid usage: %s", usage) + } + }, + } +} diff --git a/tools/dawgrun/pkg/commands/types.go b/tools/dawgrun/pkg/commands/types.go new file mode 100644 index 0000000..893bdc6 --- /dev/null +++ b/tools/dawgrun/pkg/commands/types.go @@ -0,0 +1,162 @@ +package commands + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "strings" + "sync" + + "github.com/charmbracelet/lipgloss" + "github.com/specterops/dawgs/graph" + "github.com/specterops/go-repl" + + "github.com/specterops/dawgs/tools/dawgrun/pkg/stubs" +) + +type ( + // CommandFn is the executable function for a single REPL command invocation. + CommandFn func(*CommandContext, []string) error + // CommandContext carries per-invocation context and shared command state. + CommandContext struct { + context.Context + + // instance is the running instance of the repl.Repl + instance *repl.Repl + // output is a convenience type to make issuing warnings and formatting outputs easier + output *CommandOutput + + // scope is a singleton instance held by the command manager that holds any persistent state for a command. + scope *Scope + } + // CommandDesc defines a command's behavior, arguments, and flag lifecycle. + CommandDesc struct { + // Fn is the command function to execute + Fn CommandFn + // ClearFlagsFn is used to clear a command's flags after execution + ClearFlagsFn func() + args []string + flags *flag.FlagSet + desc string + help string + state map[string]any + } + // CommandOutput accumulates output text and warnings for a command. + CommandOutput struct { + warnings []string + outputBuilder strings.Builder + } + // Scope holds persistent command state that can be shared across invocations. + Scope struct { + mu sync.RWMutex + connections map[string]graph.Database + connKindMaps map[string]stubs.KindMap + } +) + +// NewCommandContext creates a command context with a fresh output buffer. +func NewCommandContext(ctx context.Context, instance *repl.Repl, scope *Scope) *CommandContext { + return &CommandContext{ + Context: ctx, + output: new(CommandOutput), + instance: instance, + scope: scope, + } +} + +func (cc *CommandContext) warningStyle(text string) string { + return lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("202")). + Render(text) +} + +// OutputString renders warnings followed by command output text. +func (cc *CommandContext) OutputString() string { + builder := new(strings.Builder) + for _, warning := range cc.output.warnings { + fmt.Fprintf(builder, " * %s\n\n", cc.warningStyle(warning)) + } + + builder.WriteString(cc.output.outputBuilder.String()) + + return builder.String() +} + +var _ (io.Writer) = (*CommandOutput)(nil) + +// Warn appends a warning message to the command output. +func (co *CommandOutput) Warn(text string) { + co.warnings = append(co.warnings, text) +} + +// Warnf formats and appends a warning message to the command output. +func (co *CommandOutput) Warnf(text string, args ...any) { + co.warnings = append(co.warnings, fmt.Sprintf(text, args...)) +} + +// Write implements io.Writer for CommandOutput. +func (co *CommandOutput) Write(p []byte) (n int, err error) { + return co.outputBuilder.Write(p) +} + +// WriteIndented writes text after applying tab-based indentation per line. +func (co *CommandOutput) WriteIndented(text string, indentCount int) { + co.outputBuilder.WriteString(indentLines(text, indentCount)) +} + +// WriteHighlighted writes syntax-highlighted text using the configured style. +func (co *CommandOutput) WriteHighlighted(text, lexer string) { + style := os.Getenv("DAWGRUN_STYLE") + if style == "" { + style = "monokai" + } + + co.WriteHighlightedWithStyle(text, lexer, style) +} + +// WriteHighlightedWithStyle writes syntax-highlighted text with an explicit style. +func (co *CommandOutput) WriteHighlightedWithStyle(text, lexer, style string) { + highlighted, err := highlightText(text, lexer, style) + if err != nil { + co.Warnf("Could not highlight source text: %#v", err) + co.outputBuilder.WriteString(text) + } else { + co.outputBuilder.WriteString(highlighted) + } +} + +// NewScope creates an empty shared scope for command state. +func NewScope() *Scope { + return &Scope{ + connections: make(map[string]graph.Database), + connKindMaps: make(map[string]stubs.KindMap), + } +} + +// GetNumConnections returns the number of tracked database connections. +func (s *Scope) GetNumConnections() int { + s.mu.RLock() + defer s.mu.RUnlock() + + return len(s.connections) +} + +// AddConnection stores or replaces a named database connection in scope. +func (s *Scope) AddConnection(name string, querier graph.Database) { + s.mu.Lock() + defer s.mu.Unlock() + + s.connections[name] = querier +} + +// DropConnection removes a named connection and any cached kind map. +func (s *Scope) DropConnection(name string) { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.connections, name) + delete(s.connKindMaps, name) +} diff --git a/tools/dawgrun/pkg/stubs/kindmapper.go b/tools/dawgrun/pkg/stubs/kindmapper.go new file mode 100644 index 0000000..6dd4361 --- /dev/null +++ b/tools/dawgrun/pkg/stubs/kindmapper.go @@ -0,0 +1,107 @@ +// Package stubs has various bits and bobs to stub out internal behaviors +package stubs + +import ( + "context" + "errors" + "fmt" + + "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/graph" +) + +var ( + ErrNoSuchKind = errors.New("no such kind") + ErrNoSuchKindID = errors.New("no such kind with ID") +) + +type KindMap map[int16]graph.Kind + +func (km KindMap) Invert() map[graph.Kind]int16 { + inverse := make(map[graph.Kind]int16) + for kindID, kind := range km { + inverse[kind] = kindID + } + + return inverse +} + +type DumbKindMapper struct { + idToKind KindMap + kindToID map[graph.Kind]int16 + lastID int16 +} + +var _ pgsql.KindMapper = (*DumbKindMapper)(nil) + +func EmptyMapper() *DumbKindMapper { + return &DumbKindMapper{ + idToKind: make(KindMap), + kindToID: make(map[graph.Kind]int16), + lastID: -1, + } +} + +func MapperFromKindMap(kindMap KindMap) *DumbKindMapper { + idToKind := make(KindMap, len(kindMap)) + lastID := int16(-1) + for id, kind := range kindMap { + idToKind[id] = kind + if id > lastID { + lastID = id + } + } + + return &DumbKindMapper{ + idToKind: idToKind, + kindToID: idToKind.Invert(), + lastID: lastID, + } +} + +func (k *DumbKindMapper) MapKinds(ctx context.Context, kinds graph.Kinds) ([]int16, error) { + kindIDs := make([]int16, len(kinds)) + for idx, kind := range kinds { + if mappedKindID, ok := k.kindToID[kind]; !ok { + return nil, fmt.Errorf("%w: %v", ErrNoSuchKind, kind) + } else { + kindIDs[idx] = mappedKindID + } + } + return kindIDs, nil +} + +// AssertKinds tries to return IDs of `graph.Kind`s that are already known, inserting any kinds not known +// into the schema. +func (k *DumbKindMapper) AssertKinds(ctx context.Context, kinds graph.Kinds) ([]int16, error) { + kindIDs := make([]int16, 0) + for _, kind := range kinds { + if mappedKindID, ok := k.kindToID[kind]; !ok { + newID := k.lastID + 1 + k.lastID += 1 + k.kindToID[kind] = newID + k.idToKind[newID] = kind + kindIDs = append(kindIDs, newID) + } else { + kindIDs = append(kindIDs, mappedKindID) + } + } + + return kindIDs, nil +} + +func (k *DumbKindMapper) GetKindByID(id int16) (graph.Kind, error) { + if kind, ok := k.idToKind[id]; !ok { + return nil, fmt.Errorf("%w: %d", ErrNoSuchKindID, id) + } else { + return kind, nil + } +} + +func (k *DumbKindMapper) GetIDByKind(kind graph.Kind) (int16, error) { + if kindID, ok := k.kindToID[kind]; !ok { + return -1, fmt.Errorf("%w: %v", ErrNoSuchKind, kind) + } else { + return kindID, nil + } +}