├── .github └── workflows │ ├── compatibility.yml │ └── test.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── Makefile ├── README.md ├── assets ├── demo-html.png ├── demo-terminal-2.png └── godump.png ├── go.mod ├── go.sum ├── godump.go └── godump_test.go /.github/workflows/compatibility.yml: -------------------------------------------------------------------------------- 1 | name: Compatibility Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | # Run weekly on Sundays at 3 AM UTC 10 | - cron: "0 3 * * 0" 11 | 12 | jobs: 13 | go-compatibility: 14 | name: Go ${{ matrix.go-version }} 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 10 17 | strategy: 18 | matrix: 19 | go-version: ["1.23", "oldstable", "stable"] 20 | 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Go ${{ matrix.go-version }} 26 | uses: actions/setup-go@v5 27 | with: 28 | go-version: ${{ matrix.go-version }} 29 | 30 | - name: Run tests 31 | run: go test ./... -v -race 32 | timeout-minutes: 8 33 | 34 | security: 35 | name: Security Check 36 | runs-on: ubuntu-latest 37 | timeout-minutes: 8 38 | 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@v4 42 | 43 | - name: Set up Go 44 | uses: actions/setup-go@v5 45 | with: 46 | go-version: "stable" 47 | 48 | - name: Check go.mod is tidy 49 | run: | 50 | go mod tidy 51 | git diff --exit-code go.mod go.sum 52 | timeout-minutes: 2 53 | 54 | - name: Check for vulnerabilities 55 | run: | 56 | go install golang.org/x/vuln/cmd/govulncheck@latest 57 | govulncheck ./... 58 | timeout-minutes: 5 59 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Go Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version-file: go.mod 21 | 22 | - name: Install dependencies 23 | run: go mod tidy 24 | 25 | - name: Check code modernization 26 | run: make modernize-check 27 | 28 | - name: Run tests 29 | run: go test ./... -v 30 | 31 | - name: golangci-lint 32 | uses: golangci/golangci-lint-action@v7 33 | with: 34 | version: v2.1 35 | args: --issues-exit-code=1 --timeout 5m 36 | only-new-issues: false 37 | 38 | - name: Run tests with coverage 39 | run: go test -coverprofile=coverage.txt 40 | 41 | - name: Upload results to Codecov 42 | uses: codecov/codecov-action@v5 43 | env: 44 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | cover.out 4 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | linters: 4 | default: all 5 | disable: 6 | # redundant 7 | - cyclop # revive 8 | - funlen # revive 9 | - gocognit # revive 10 | - gocyclo # revive 11 | - lll # revive 12 | - unparam # revive 13 | 14 | # too strict 15 | - depguard 16 | - exhaustive 17 | - exhaustruct 18 | - gochecknoglobals 19 | - godot 20 | - mnd 21 | - paralleltest 22 | - testpackage 23 | - varnamelen 24 | 25 | # strict formatting 26 | - nlreturn 27 | - whitespace 28 | - wsl 29 | 30 | # nice to have 31 | - usetesting 32 | - unused 33 | - testifylint 34 | - perfsprint 35 | 36 | settings: 37 | 38 | errcheck: 39 | check-type-assertions: true 40 | 41 | gocritic: 42 | enable-all: true 43 | disabled-checks: 44 | - unnamedResult 45 | - sloppyLen 46 | - singleCaseSwitch 47 | 48 | govet: 49 | disable: 50 | - fieldalignment 51 | enable-all: true 52 | 53 | maintidx: 54 | # higher = better 55 | under: 20 56 | 57 | misspell: 58 | locale: US 59 | 60 | nestif: 61 | # lower=better 62 | min-complexity: 5 63 | 64 | # nlreturn: 65 | # block-size: 5 66 | 67 | nolintlint: 68 | require-explanation: false # don't require an explanation for nolint directives 69 | require-specific: false # don't require nolint directives to be specific about which linter is being skipped 70 | allow-unused: false # report any unused nolint directives 71 | 72 | revive: 73 | severity: error 74 | enable-all-rules: true 75 | rules: 76 | - name: function-length 77 | disabled: true 78 | - name: add-constant 79 | disabled: true 80 | - name: cognitive-complexity 81 | arguments: 82 | - 50 83 | - name: cyclomatic 84 | arguments: 85 | # lower=better 86 | - 40 87 | - name: empty-lines 88 | disabled: true 89 | - name: line-length-limit 90 | arguments: 91 | - 160 92 | - name: max-control-nesting 93 | arguments: 94 | # lower=better 95 | - 4 96 | - name: max-public-structs 97 | disabled: true 98 | - name: indent-error-flow 99 | disabled: true 100 | - name: package-comments 101 | disabled: true 102 | - name: unnecessary-stmt 103 | disabled: true 104 | - name: unused-parameter 105 | disabled: true 106 | - name: unused-receiver 107 | disabled: true 108 | 109 | staticcheck: 110 | checks: 111 | - all 112 | - -ST1000 113 | 114 | wsl: 115 | allow-trailing-comment: true 116 | 117 | exclusions: 118 | presets: 119 | - common-false-positives 120 | - legacy 121 | - std-error-handling 122 | 123 | issues: 124 | max-issues-per-linter: 0 125 | max-same-issues: 0 126 | 127 | formatters: 128 | settings: 129 | gci: 130 | sections: 131 | - standard 132 | - default 133 | - prefix(github.com/goforj/godump) 134 | 135 | exclusions: 136 | paths: 137 | - third_party$ 138 | - builtin$ 139 | - examples$ 140 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 goforj 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the “Software”), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: modernize modernize-fix modernize-check 2 | 3 | MODERNIZE_CMD = go run golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@v0.18.1 4 | 5 | modernize: modernize-fix 6 | 7 | modernize-fix: 8 | @echo "Running gopls modernize with -fix..." 9 | $(MODERNIZE_CMD) -test -fix ./... 10 | 11 | modernize-check: 12 | @echo "Checking if code needs modernization..." 13 | $(MODERNIZE_CMD) -test ./... 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | <p align="center"> 2 | <img src="./assets/godump.png" width="600" alt="godump logo"> 3 | </p> 4 | 5 | <p align="center"> 6 | Pretty-print and debug Go structs with a Laravel-inspired developer experience. 7 | </p> 8 | 9 | <p align="center"> 10 | <a href="https://pkg.go.dev/github.com/goforj/godump"><img src="https://pkg.go.dev/badge/github.com/goforj/godump.svg" alt="Go Reference"></a> 11 | <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a> 12 | <a href="https://github.com/goforj/godump/actions"><img src="https://github.com/goforj/godump/actions/workflows/test.yml/badge.svg" alt="Go Test"></a> 13 | <a href="https://golang.org"><img src="https://img.shields.io/badge/go-1.23+-blue?logo=go" alt="Go version"></a> 14 | <img src="https://img.shields.io/github/v/tag/goforj/godump?label=version&sort=semver" alt="Latest tag"> 15 | <a href="https://goreportcard.com/report/github.com/goforj/godump"><img src="https://goreportcard.com/badge/github.com/goforj/godump" alt="Go Report Card"></a> 16 | <a href="https://codecov.io/gh/goforj/godump" ><img src="https://codecov.io/gh/goforj/godump/graph/badge.svg?token=ULUTXL03XC"/></a> 17 | <a href="https://github.com/avelino/awesome-go?tab=readme-ov-file#parsersencodersdecoders"><img src="https://awesome.re/mentioned-badge-flat.svg" alt="Mentioned in Awesome Go"></a> 18 | </p> 19 | 20 | <p align="center"> 21 | <code>godump</code> is a developer-friendly, zero-dependency debug dumper for Go. It provides pretty, colorized terminal output of your structs, slices, maps, and more — complete with cyclic reference detection and control character escaping. 22 | Inspired by Symfony's VarDumper which is used in Laravel's tools like <code>dump()</code> and <code>dd()</code>. 23 | </p> 24 | 25 | <br> 26 | 27 | <p align="center"> 28 | <strong>Terminal Output Example (Kitchen Sink)</strong><br> 29 | <img src="./assets/demo-terminal-2.png"> 30 | </p> 31 | 32 | <p align="center"> 33 | <strong>HTML Output Example</strong><br> 34 | <img src="./assets/demo-html.png"> 35 | </p> 36 | 37 | ## ✨ Features 38 | 39 | - 🧠 Struct field inspection with visibility markers (`+`, `-`) 40 | - 🔄 Cycle-safe reference tracking 41 | - 🎨 ANSI color or HTML output 42 | - 🧪 Handles slices, maps, nested structs, pointers, time, etc. 43 | - 🪄 Control character escaping (`\n`, `\t`, etc.) 44 | 45 | ## 📦 Installation 46 | 47 | ```bash 48 | go get github.com/goforj/godump 49 | ```` 50 | 51 | ## 🚀 Basic Usage 52 | 53 | ```go 54 | package main 55 | 56 | import ( 57 | "fmt" 58 | "os" 59 | "strings" 60 | "github.com/goforj/godump" 61 | ) 62 | 63 | type Profile struct { 64 | Age int 65 | Email string 66 | } 67 | 68 | type User struct { 69 | Name string 70 | Profile Profile 71 | } 72 | 73 | func main() { 74 | user := User{ 75 | Name: "Alice", 76 | Profile: Profile{ 77 | Age: 30, 78 | Email: "alice@example.com", 79 | }, 80 | } 81 | 82 | // Pretty-print to stdout 83 | godump.Dump(user) 84 | 85 | // Get dump as string 86 | output := godump.DumpStr(user) 87 | fmt.Println("str", output) 88 | 89 | // HTML for web UI output 90 | html := godump.DumpHTML(user) 91 | fmt.Println("html", html) 92 | 93 | // Print JSON directly to stdout 94 | godump.DumpJSON(user) 95 | 96 | // Write to any io.Writer (e.g. file, buffer, logger) 97 | godump.Fdump(os.Stderr, user) 98 | 99 | // Dump and exit 100 | godump.Dd(user) // this will print the dump and exit the program 101 | } 102 | ``` 103 | 104 | ## 🧪 Example Output 105 | 106 | ```go 107 | <#dump // main.go:26 108 | #main.User 109 | +Name => "Alice" 110 | +Profile => #main.Profile 111 | +Age => 30 112 | +Email => "alice@example.com" 113 | } 114 | } 115 | ``` 116 | 117 | ## 🏗️ Builder Options Usage 118 | 119 | ```go 120 | package main 121 | 122 | import ( 123 | "fmt" 124 | "os" 125 | "strings" 126 | "github.com/goforj/godump" 127 | ) 128 | 129 | type Profile struct { 130 | Age int 131 | Email string 132 | } 133 | 134 | type User struct { 135 | Name string 136 | Profile Profile 137 | } 138 | 139 | func main() { 140 | user := User{ 141 | Name: "Alice", 142 | Profile: Profile{ 143 | Age: 30, 144 | Email: "alice@example.com", 145 | }, 146 | } 147 | 148 | // Custom Dumper with all options set explicitly 149 | d := godump.NewDumper( 150 | godump.WithMaxDepth(15), // default: 15 151 | godump.WithMaxItems(100), // default: 100 152 | godump.WithMaxStringLen(100000), // default: 100000 153 | godump.WithWriter(os.Stdout), // default: os.Stdout 154 | ) 155 | 156 | // Use the custom dumper 157 | d.Dump(user) 158 | 159 | // Dump to string 160 | out := d.DumpStr(user) 161 | fmt.Printf("DumpStr output:\n%s\n", out) 162 | 163 | // Dump to HTML string 164 | html := d.DumpHTML(user) 165 | fmt.Printf("DumpHTML output:\n%s\n", html) 166 | 167 | // Dump JSON using the Dumper (returns string) 168 | jsonStr := d.DumpJSONStr(user) 169 | fmt.Printf("Dumper JSON string:\n%s\n", jsonStr) 170 | 171 | // Print JSON directly from the Dumper 172 | d.DumpJSON(user) 173 | 174 | // Dump to custom writer (e.g. a string builder) 175 | var sb strings.Builder 176 | custom := godump.NewDumper(godump.WithWriter(&sb)) 177 | custom.Dump(user) 178 | fmt.Printf("Dump to string builder:\n%s\n", sb.String()) 179 | } 180 | ``` 181 | 182 | ## 📘 How to Read the Output 183 | 184 | `godump` output is designed for clarity and traceability. Here's how to interpret its structure: 185 | 186 | ### 🧭 Location Header 187 | 188 | ```go 189 | <#dump // main.go:26 190 | ```` 191 | 192 | * The first line shows the **file and line number** where `godump.Dump()` was invoked. 193 | * Helpful for finding where the dump happened during debugging. 194 | 195 | ### 🔎 Type Names 196 | 197 | ```go 198 | #main.User 199 | ``` 200 | 201 | * Fully qualified struct name with its package path. 202 | 203 | ### 🔐 Visibility Markers 204 | 205 | ```go 206 | +Name => "Alice" 207 | -secret => "..." 208 | ``` 209 | 210 | * `+` → Exported (public) field 211 | * `-` → Unexported (private) field (accessed reflectively) 212 | 213 | ### 🔄 Cyclic References 214 | 215 | If a pointer has already been printed: 216 | 217 | ```go 218 | ↩︎ &1 219 | ``` 220 | 221 | * Prevents infinite loops in circular structures 222 | * References point back to earlier object instances 223 | 224 | ### 🔢 Slices and Maps 225 | 226 | ```go 227 | 0 => "value" 228 | a => 1 229 | ``` 230 | 231 | * Array/slice indices and map keys are shown with `=>` formatting and indentation 232 | * Slices and maps are truncated if `maxItems` is exceeded 233 | 234 | ### 🔣 Escaped Characters 235 | 236 | ```go 237 | "Line1\nLine2\tDone" 238 | ``` 239 | 240 | * Control characters like `\n`, `\t`, `\r`, etc. are safely escaped 241 | * Strings are truncated after `maxStringLen` runes 242 | 243 | ### 🧩 Supported Types 244 | 245 | * ✅ Structs (exported & unexported) 246 | * ✅ Pointers, interfaces 247 | * ✅ Maps, slices, arrays 248 | * ✅ Channels, functions 249 | * ✅ time.Time (nicely formatted) 250 | 251 | ## 🧩 License 252 | 253 | MIT © [goforj](https://github.com/goforj) 254 | 255 | ## 📇 Author 256 | 257 | Created by [Chris Miles](https://github.com/akkadius) 258 | Maintained as part of the [goforj](https://github.com/goforj) tooling ecosystem. 259 | -------------------------------------------------------------------------------- /assets/demo-html.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goforj/godump/c7f103e424e6a6fb305c1c68707061011afc9305/assets/demo-html.png -------------------------------------------------------------------------------- /assets/demo-terminal-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goforj/godump/c7f103e424e6a6fb305c1c68707061011afc9305/assets/demo-terminal-2.png -------------------------------------------------------------------------------- /assets/godump.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goforj/godump/c7f103e424e6a6fb305c1c68707061011afc9305/assets/godump.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/goforj/godump 2 | 3 | go 1.23 4 | 5 | require github.com/stretchr/testify v1.10.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 6 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /godump.go: -------------------------------------------------------------------------------- 1 | package godump 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "reflect" 10 | "runtime" 11 | "strings" 12 | "text/tabwriter" 13 | "unicode/utf8" 14 | "unsafe" 15 | ) 16 | 17 | const ( 18 | colorReset = "\033[0m" 19 | colorGray = "\033[90m" 20 | colorYellow = "\033[33m" 21 | colorLime = "\033[1;38;5;113m" 22 | colorCyan = "\033[38;5;38m" 23 | colorNote = "\033[38;5;38m" 24 | colorRef = "\033[38;5;247m" 25 | colorMeta = "\033[38;5;170m" 26 | colorDefault = "\033[38;5;208m" 27 | indentWidth = 2 28 | ) 29 | 30 | // Default configuration values for the Dumper. 31 | const ( 32 | defaultMaxDepth = 15 33 | defaultMaxItems = 100 34 | defaultMaxStringLen = 100000 35 | defaultMaxStackDepth = 10 36 | initialCallerSkip = 2 37 | ) 38 | 39 | // defaultDumper is the default Dumper instance used by Dump and DumpStr functions. 40 | var defaultDumper = NewDumper() 41 | 42 | // exitFunc is a function that can be overridden for testing purposes. 43 | var exitFunc = os.Exit 44 | 45 | var ( 46 | enableColor = detectColor() 47 | nextRefID = 1 48 | referenceMap = map[uintptr]int{} 49 | ) 50 | 51 | // Colorizer is a function type that takes a color code and a string, returning the colorized string. 52 | type Colorizer func(code, str string) string 53 | 54 | // colorize is the default colorizer function. 55 | var colorize Colorizer = ansiColorize // default 56 | 57 | // ansiColorize colorizes the string using ANSI escape codes. 58 | func ansiColorize(code, str string) string { 59 | if !enableColor { 60 | return str 61 | } 62 | return code + str + colorReset 63 | } 64 | 65 | // htmlColorMap maps color codes to HTML colors. 66 | var htmlColorMap = map[string]string{ 67 | colorGray: "#999", 68 | colorYellow: "#ffb400", 69 | colorLime: "#80ff80", 70 | colorNote: "#40c0ff", 71 | colorRef: "#aaa", 72 | colorMeta: "#d087d0", 73 | colorDefault: "#ff7f00", 74 | } 75 | 76 | // htmlColorize colorizes the string using HTML span tags. 77 | func htmlColorize(code, str string) string { 78 | return fmt.Sprintf(`<span style="color:%s">%s</span>`, htmlColorMap[code], str) 79 | } 80 | 81 | // Dumper holds configuration for dumping structured data. 82 | // It controls depth, item count, and string length limits. 83 | type Dumper struct { 84 | maxDepth int 85 | maxItems int 86 | maxStringLen int 87 | writer io.Writer 88 | skippedStackFrames int 89 | 90 | // callerFn is used to get the caller information. 91 | // It defaults to [runtime.Caller], it is here to be overridden for testing purposes. 92 | callerFn func(skip int) (uintptr, string, int, bool) 93 | } 94 | 95 | // Option defines a functional option for configuring a Dumper. 96 | type Option func(*Dumper) *Dumper 97 | 98 | // WithMaxDepth allows to control how deep the structure will be dumped. 99 | // Param n must be 0 or greater or this will be ignored, and default MaxDepth will be 15 100 | func WithMaxDepth(n int) Option { 101 | return func(d *Dumper) *Dumper { 102 | if n >= 0 { 103 | d.maxDepth = n 104 | } 105 | return d 106 | } 107 | } 108 | 109 | // WithMaxItems allows to control how many items from an array, slice or maps can be printed. 110 | // Param n must be 0 or greater or this will be ignored, and default MaxItems will be 100 111 | func WithMaxItems(n int) Option { 112 | return func(d *Dumper) *Dumper { 113 | if n >= 0 { 114 | d.maxItems = n 115 | } 116 | return d 117 | } 118 | } 119 | 120 | // WithMaxStringLen allows to control how long can printed strings be. 121 | // Param n must be 0 or greater or this will be ignored, and default MaxStringLen will be 100000 122 | func WithMaxStringLen(n int) Option { 123 | return func(d *Dumper) *Dumper { 124 | if n >= 0 { 125 | d.maxStringLen = n 126 | } 127 | return d 128 | } 129 | } 130 | 131 | // WithWriter allows to control the io output. 132 | func WithWriter(w io.Writer) Option { 133 | return func(d *Dumper) *Dumper { 134 | d.writer = w 135 | return d 136 | } 137 | } 138 | 139 | // WithSkipStackFrames allows users to skip additional stack frames 140 | // on top of the frames that godump already skips internally. 141 | // This is useful when godump is wrapped in other functions or utilities, 142 | // and the actual call site is deeper in the stack. 143 | func WithSkipStackFrames(n int) Option { 144 | return func(d *Dumper) *Dumper { 145 | if n >= 0 { 146 | d.skippedStackFrames = n 147 | } 148 | return d 149 | } 150 | } 151 | 152 | // NewDumper creates a new Dumper with the given options applied. 153 | // Defaults are used for any setting not overridden. 154 | func NewDumper(opts ...Option) *Dumper { 155 | d := &Dumper{ 156 | maxDepth: defaultMaxDepth, 157 | maxItems: defaultMaxItems, 158 | maxStringLen: defaultMaxStringLen, 159 | writer: os.Stdout, 160 | callerFn: runtime.Caller, 161 | } 162 | for _, opt := range opts { 163 | d = opt(d) 164 | } 165 | return d 166 | } 167 | 168 | // Dump prints the values to stdout with colorized output. 169 | func Dump(vs ...any) { 170 | defaultDumper.Dump(vs...) 171 | } 172 | 173 | // Dump prints the values to stdout with colorized output. 174 | func (d *Dumper) Dump(vs ...any) { 175 | d.printDumpHeader(d.writer) 176 | tw := tabwriter.NewWriter(d.writer, 0, 0, 1, ' ', 0) 177 | d.writeDump(tw, vs...) 178 | tw.Flush() 179 | } 180 | 181 | // Fdump writes the formatted dump of values to the given io.Writer. 182 | func Fdump(w io.Writer, vs ...any) { 183 | NewDumper(WithWriter(w)).Dump(vs...) 184 | } 185 | 186 | // DumpStr returns a string representation of the values with colorized output. 187 | func DumpStr(vs ...any) string { 188 | return defaultDumper.DumpStr(vs...) 189 | } 190 | 191 | // DumpStr returns a string representation of the values with colorized output. 192 | func (d *Dumper) DumpStr(vs ...any) string { 193 | var sb strings.Builder 194 | d.printDumpHeader(&sb) 195 | tw := tabwriter.NewWriter(&sb, 0, 0, 1, ' ', 0) 196 | d.writeDump(tw, vs...) 197 | tw.Flush() 198 | return sb.String() 199 | } 200 | 201 | // DumpJSONStr pretty-prints values as JSON and returns it as a string. 202 | func (d *Dumper) DumpJSONStr(vs ...any) string { 203 | if len(vs) == 0 { 204 | return `{"error": "DumpJSON called with no arguments"}` 205 | } 206 | 207 | var data any = vs 208 | if len(vs) == 1 { 209 | data = vs[0] 210 | } 211 | 212 | b, err := json.MarshalIndent(data, "", strings.Repeat(" ", indentWidth)) 213 | if err != nil { 214 | //nolint:errchkjson // fallback handles this manually below 215 | errorJSON, _ := json.Marshal(map[string]string{"error": err.Error()}) 216 | return string(errorJSON) 217 | } 218 | return string(b) 219 | } 220 | 221 | // DumpJSON prints a pretty-printed JSON string to the configured writer. 222 | func (d *Dumper) DumpJSON(vs ...any) { 223 | output := d.DumpJSONStr(vs...) 224 | fmt.Fprintln(d.writer, output) 225 | } 226 | 227 | // DumpHTML dumps the values as HTML with colorized output. 228 | func DumpHTML(vs ...any) string { 229 | return defaultDumper.DumpHTML(vs...) 230 | } 231 | 232 | // DumpHTML dumps the values as HTML with colorized output. 233 | func (d *Dumper) DumpHTML(vs ...any) string { 234 | prevColorize := colorize 235 | prevEnable := enableColor 236 | defer func() { 237 | colorize = prevColorize 238 | enableColor = prevEnable 239 | }() 240 | 241 | // Enable HTML coloring 242 | colorize = htmlColorize 243 | enableColor = true 244 | 245 | var sb strings.Builder 246 | sb.WriteString(`<div style='background-color:black;'><pre style="background-color:black; color:white; padding:5px; border-radius: 5px">` + "\n") 247 | 248 | tw := tabwriter.NewWriter(&sb, 0, 0, 1, ' ', 0) 249 | d.printDumpHeader(&sb) 250 | d.writeDump(tw, vs...) 251 | tw.Flush() 252 | 253 | sb.WriteString("</pre></div>") 254 | return sb.String() 255 | } 256 | 257 | // DumpJSON dumps the values as a pretty-printed JSON string. 258 | // If there is more than one value, they are dumped as a JSON array. 259 | // It returns an error string if marshaling fails. 260 | func DumpJSON(vs ...any) { 261 | defaultDumper.DumpJSON(vs...) 262 | } 263 | 264 | // DumpJSONStr dumps the values as a JSON string. 265 | func DumpJSONStr(vs ...any) string { 266 | return defaultDumper.DumpJSONStr(vs...) 267 | } 268 | 269 | // Dd is a debug function that prints the values and exits the program. 270 | func Dd(vs ...any) { 271 | defaultDumper.Dd(vs...) 272 | } 273 | 274 | // Dd is a debug function that prints the values and exits the program. 275 | func (d *Dumper) Dd(vs ...any) { 276 | d.Dump(vs...) 277 | exitFunc(1) 278 | } 279 | 280 | // printDumpHeader prints the header for the dump output, including the file and line number. 281 | func (d *Dumper) printDumpHeader(out io.Writer) { 282 | file, line := d.findFirstNonInternalFrame(d.skippedStackFrames) 283 | if file == "" { 284 | return 285 | } 286 | 287 | relPath := file 288 | if wd, err := os.Getwd(); err == nil { 289 | if rel, err := filepath.Rel(wd, file); err == nil { 290 | relPath = rel 291 | } 292 | } 293 | 294 | header := fmt.Sprintf("<#dump // %s:%d", relPath, line) 295 | fmt.Fprintln(out, colorize(colorGray, header)) 296 | } 297 | 298 | // findFirstNonInternalFrame iterates through the call stack to find the first non-internal frame. 299 | func (d *Dumper) findFirstNonInternalFrame(skip int) (string, int) { 300 | for i := initialCallerSkip; i < defaultMaxStackDepth; i++ { 301 | pc, file, line, ok := d.callerFn(i) 302 | if !ok { 303 | break 304 | } 305 | fn := runtime.FuncForPC(pc) 306 | if fn == nil || !strings.Contains(fn.Name(), "godump") || strings.HasSuffix(file, "_test.go") { 307 | if skip > 0 { 308 | skip-- 309 | continue 310 | } 311 | 312 | return file, line 313 | } 314 | } 315 | return "", 0 316 | } 317 | 318 | // formatByteSliceAsHexDump formats a byte slice as a hex dump with ASCII representation. 319 | func formatByteSliceAsHexDump(b []byte, indent int) string { 320 | var sb strings.Builder 321 | 322 | const lineLen = 16 323 | const asciiStartCol = 50 324 | const asciiMaxLen = 16 325 | 326 | fieldIndent := strings.Repeat(" ", indent*indentWidth) 327 | bodyIndent := fieldIndent 328 | 329 | // Header 330 | sb.WriteString(fmt.Sprintf("([]uint8) (len=%d cap=%d) {\n", len(b), cap(b))) 331 | 332 | for i := 0; i < len(b); i += lineLen { 333 | end := min(i+lineLen, len(b)) 334 | line := b[i:end] 335 | 336 | visibleLen := 0 337 | 338 | // Offset 339 | offsetStr := fmt.Sprintf("%08x ", i) 340 | sb.WriteString(bodyIndent) 341 | sb.WriteString(colorize(colorMeta, offsetStr)) 342 | visibleLen += len(offsetStr) 343 | 344 | // Hex bytes 345 | for j := range lineLen { 346 | var hexStr string 347 | if j < len(line) { 348 | hexStr = fmt.Sprintf("%02x ", line[j]) 349 | } else { 350 | hexStr = " " 351 | } 352 | if j == 7 { 353 | hexStr += " " 354 | } 355 | sb.WriteString(colorize(colorCyan, hexStr)) 356 | visibleLen += len(hexStr) 357 | } 358 | 359 | // Padding before ASCII 360 | padding := max(1, asciiStartCol-visibleLen) 361 | sb.WriteString(strings.Repeat(" ", padding)) 362 | 363 | // ASCII section 364 | sb.WriteString(colorize(colorGray, "| ")) 365 | asciiCount := 0 366 | for _, c := range line { 367 | ch := "." 368 | if c >= 32 && c <= 126 { 369 | ch = string(c) 370 | } 371 | sb.WriteString(colorize(colorLime, ch)) 372 | asciiCount++ 373 | } 374 | if asciiCount < asciiMaxLen { 375 | sb.WriteString(strings.Repeat(" ", asciiMaxLen-asciiCount)) 376 | } 377 | sb.WriteString(colorize(colorGray, " |") + "\n") 378 | } 379 | 380 | // Closing 381 | fieldIndent = fieldIndent[:len(fieldIndent)-indentWidth] 382 | sb.WriteString(fieldIndent + "}") 383 | return sb.String() 384 | } 385 | 386 | func (d *Dumper) writeDump(tw *tabwriter.Writer, vs ...any) { 387 | referenceMap = map[uintptr]int{} // reset each time 388 | visited := map[uintptr]bool{} 389 | for _, v := range vs { 390 | rv := reflect.ValueOf(v) 391 | rv = makeAddressable(rv) 392 | d.printValue(tw, rv, 0, visited) 393 | fmt.Fprintln(tw) 394 | } 395 | } 396 | 397 | func (d *Dumper) printValue(tw *tabwriter.Writer, v reflect.Value, indent int, visited map[uintptr]bool) { 398 | if indent > d.maxDepth { 399 | fmt.Fprint(tw, colorize(colorGray, "... (max depth)")) 400 | return 401 | } 402 | if !v.IsValid() { 403 | fmt.Fprint(tw, colorize(colorGray, "<invalid>")) 404 | return 405 | } 406 | 407 | if s := asStringer(v); s != "" { 408 | fmt.Fprint(tw, s) 409 | return 410 | } 411 | 412 | switch v.Kind() { 413 | case reflect.Chan: 414 | if v.IsNil() { 415 | fmt.Fprint(tw, colorize(colorGray, v.Type().String()+"(nil)")) 416 | } else { 417 | fmt.Fprintf(tw, "%s(%s)", colorize(colorGray, v.Type().String()), colorize(colorCyan, fmt.Sprintf("%#x", v.Pointer()))) 418 | } 419 | return 420 | } 421 | 422 | if isNil(v) { 423 | typeStr := v.Type().String() 424 | fmt.Fprintf(tw, colorize(colorLime, typeStr)+colorize(colorGray, "(nil)")) 425 | return 426 | } 427 | 428 | if v.Kind() == reflect.Ptr && v.CanAddr() { 429 | ptr := v.Pointer() 430 | if id, ok := referenceMap[ptr]; ok { 431 | fmt.Fprintf(tw, colorize(colorRef, "↩︎ &%d"), id) 432 | return 433 | } else { 434 | referenceMap[ptr] = nextRefID 435 | nextRefID++ 436 | } 437 | } 438 | 439 | switch v.Kind() { 440 | case reflect.Ptr, reflect.Interface: 441 | d.printValue(tw, v.Elem(), indent, visited) 442 | case reflect.Struct: 443 | t := v.Type() 444 | fmt.Fprintf(tw, "%s {", colorize(colorGray, "#"+t.String())) 445 | fmt.Fprintln(tw) 446 | 447 | for i := range t.NumField() { 448 | field := t.Field(i) 449 | fieldVal := v.Field(i) 450 | 451 | symbol := "+" 452 | if field.PkgPath != "" { 453 | symbol = "-" 454 | fieldVal = forceExported(fieldVal) 455 | } 456 | indentPrint(tw, indent+1, colorize(colorYellow, symbol)+field.Name) 457 | fmt.Fprint(tw, " => ") 458 | if s := asStringer(fieldVal); s != "" { 459 | fmt.Fprint(tw, s) 460 | } else { 461 | d.printValue(tw, fieldVal, indent+1, visited) 462 | } 463 | fmt.Fprintln(tw) 464 | } 465 | indentPrint(tw, indent, "") 466 | fmt.Fprint(tw, "}") 467 | case reflect.Complex64, reflect.Complex128: 468 | fmt.Fprint(tw, colorize(colorCyan, fmt.Sprintf("%v", v.Complex()))) 469 | case reflect.UnsafePointer: 470 | fmt.Fprint(tw, colorize(colorGray, fmt.Sprintf("unsafe.Pointer(%#x)", v.Pointer()))) 471 | case reflect.Map: 472 | fmt.Fprintln(tw, "{") 473 | keys := v.MapKeys() 474 | for i, key := range keys { 475 | if i >= d.maxItems { 476 | indentPrint(tw, indent+1, colorize(colorGray, "... (truncated)")) 477 | break 478 | } 479 | keyStr := fmt.Sprintf("%v", key.Interface()) 480 | indentPrint(tw, indent+1, fmt.Sprintf(" %s => ", colorize(colorMeta, keyStr))) 481 | d.printValue(tw, v.MapIndex(key), indent+1, visited) 482 | fmt.Fprintln(tw) 483 | } 484 | indentPrint(tw, indent, "") 485 | fmt.Fprint(tw, "}") 486 | case reflect.Slice, reflect.Array: 487 | // []byte handling 488 | if v.Type().Elem().Kind() == reflect.Uint8 { 489 | if v.CanConvert(reflect.TypeOf([]byte{})) { // Check if it can be converted to []byte 490 | if data, ok := v.Convert(reflect.TypeOf([]byte{})).Interface().([]byte); ok { 491 | hexDump := formatByteSliceAsHexDump(data, indent+1) 492 | fmt.Fprint(tw, colorize(colorGray, hexDump)) 493 | break 494 | } 495 | } 496 | } 497 | 498 | // Default rendering for other slices/arrays 499 | fmt.Fprintln(tw, "[") 500 | for i := range v.Len() { 501 | if i >= d.maxItems { 502 | indentPrint(tw, indent+1, colorize(colorGray, "... (truncated)\n")) 503 | break 504 | } 505 | indentPrint(tw, indent+1, fmt.Sprintf("%s => ", colorize(colorCyan, fmt.Sprintf("%d", i)))) 506 | d.printValue(tw, v.Index(i), indent+1, visited) 507 | fmt.Fprintln(tw) 508 | } 509 | indentPrint(tw, indent, "") 510 | fmt.Fprint(tw, "]") 511 | case reflect.String: 512 | str := escapeControl(v.String()) 513 | if utf8.RuneCountInString(str) > d.maxStringLen { 514 | runes := []rune(str) 515 | str = string(runes[:d.maxStringLen]) + "…" 516 | } 517 | fmt.Fprint(tw, colorize(colorYellow, `"`)+colorize(colorLime, str)+colorize(colorYellow, `"`)) 518 | case reflect.Bool: 519 | if v.Bool() { 520 | fmt.Fprint(tw, colorize(colorYellow, "true")) 521 | } else { 522 | fmt.Fprint(tw, colorize(colorGray, "false")) 523 | } 524 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: 525 | fmt.Fprint(tw, colorize(colorCyan, fmt.Sprint(v.Int()))) 526 | case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: 527 | fmt.Fprint(tw, colorize(colorCyan, fmt.Sprint(v.Uint()))) 528 | case reflect.Float32, reflect.Float64: 529 | fmt.Fprint(tw, colorize(colorCyan, fmt.Sprintf("%f", v.Float()))) 530 | case reflect.Func: 531 | fmt.Fprint(tw, colorize(colorGray, v.Type().String())) 532 | default: 533 | // unreachable; all reflect.Kind cases are handled 534 | } 535 | } 536 | 537 | // asStringer checks if the value implements fmt.Stringer and returns its string representation. 538 | func asStringer(v reflect.Value) string { 539 | val := v 540 | if !val.CanInterface() { 541 | val = forceExported(val) 542 | } 543 | if val.CanInterface() { 544 | if s, ok := val.Interface().(fmt.Stringer); ok { 545 | rv := reflect.ValueOf(s) 546 | if rv.Kind() == reflect.Ptr && rv.IsNil() { 547 | return colorize(colorGray, val.Type().String()+"(nil)") 548 | } 549 | return colorize(colorLime, s.String()) + colorize(colorGray, " #"+val.Type().String()) 550 | } 551 | } 552 | return "" 553 | } 554 | 555 | // indentPrint prints indented text to the tabwriter. 556 | func indentPrint(tw *tabwriter.Writer, indent int, text string) { 557 | fmt.Fprint(tw, strings.Repeat(" ", indent*indentWidth)+text) 558 | } 559 | 560 | // forceExported returns a value that is guaranteed to be exported, even if it is unexported. 561 | func forceExported(v reflect.Value) reflect.Value { 562 | if v.CanInterface() { 563 | return v 564 | } 565 | if v.CanAddr() { 566 | return reflect.NewAt(v.Type(), unsafe.Pointer(v.UnsafeAddr())).Elem() 567 | } 568 | // Final fallback: return original value, even if unexported 569 | return v 570 | } 571 | 572 | // makeAddressable ensures the value is addressable, wrapping structs in pointers if necessary. 573 | func makeAddressable(v reflect.Value) reflect.Value { 574 | // Already addressable? Do nothing 575 | if v.CanAddr() { 576 | return v 577 | } 578 | 579 | // If it's a struct and not addressable, wrap it in a pointer 580 | if v.Kind() == reflect.Struct { 581 | ptr := reflect.New(v.Type()) 582 | ptr.Elem().Set(v) 583 | return ptr.Elem() 584 | } 585 | 586 | return v 587 | } 588 | 589 | // isNil checks if the value is nil based on its kind. 590 | func isNil(v reflect.Value) bool { 591 | switch v.Kind() { 592 | case reflect.Ptr, reflect.Slice, reflect.Map, reflect.Interface, reflect.Func, reflect.Chan: 593 | return v.IsNil() 594 | default: 595 | return false 596 | } 597 | } 598 | 599 | // replacer is used to escape control characters in strings. 600 | var replacer = strings.NewReplacer( 601 | "\n", `\n`, 602 | "\t", `\t`, 603 | "\r", `\r`, 604 | "\v", `\v`, 605 | "\f", `\f`, 606 | "\x1b", `\x1b`, 607 | ) 608 | 609 | // escapeControl escapes control characters in a string for safe display. 610 | func escapeControl(s string) string { 611 | return replacer.Replace(s) 612 | } 613 | 614 | // detectColor checks environment variables to determine if color output should be enabled. 615 | func detectColor() bool { 616 | if os.Getenv("NO_COLOR") != "" { 617 | return false 618 | } 619 | if os.Getenv("FORCE_COLOR") != "" { 620 | return true 621 | } 622 | return true 623 | } 624 | -------------------------------------------------------------------------------- /godump_test.go: -------------------------------------------------------------------------------- 1 | package godump 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "os" 9 | "reflect" 10 | "regexp" 11 | "strings" 12 | "testing" 13 | "text/tabwriter" 14 | "time" 15 | "unsafe" 16 | 17 | "github.com/stretchr/testify/require" 18 | 19 | "github.com/stretchr/testify/assert" 20 | ) 21 | 22 | func newDumperT(t *testing.T, opts ...Option) *Dumper { 23 | t.Helper() 24 | 25 | return NewDumper(opts...) 26 | } 27 | 28 | // stripANSI removes ANSI color codes for testable output. 29 | func stripANSI(s string) string { 30 | re := regexp.MustCompile(`\x1b\[[0-9;]*m`) 31 | return re.ReplaceAllString(s, "") 32 | } 33 | 34 | func TestSimpleStruct(t *testing.T) { 35 | type Profile struct { 36 | Age int 37 | Email string 38 | } 39 | type User struct { 40 | Name string 41 | Profile Profile 42 | } 43 | 44 | user := User{Name: "Alice", Profile: Profile{Age: 30, Email: "alice@example.com"}} 45 | out := stripANSI(DumpStr(user)) 46 | 47 | assert.Contains(t, out, "#godump.User") 48 | assert.Contains(t, out, "+Name") 49 | assert.Contains(t, out, "\"Alice\"") 50 | assert.Contains(t, out, "+Profile") 51 | assert.Contains(t, out, "#godump.Profile") 52 | assert.Contains(t, out, "+Age") 53 | assert.Contains(t, out, "30") 54 | assert.Contains(t, out, "+Email") 55 | assert.Contains(t, out, "alice@example.com") 56 | } 57 | 58 | func TestNilPointer(t *testing.T) { 59 | var s *string 60 | out := stripANSI(DumpStr(s)) 61 | assert.Contains(t, out, "(nil)") 62 | } 63 | 64 | func TestCycleReference(t *testing.T) { 65 | type Node struct { 66 | Next *Node 67 | } 68 | n := &Node{} 69 | n.Next = n 70 | out := stripANSI(DumpStr(n)) 71 | assert.Contains(t, out, "↩︎ &1") 72 | } 73 | 74 | func TestMaxDepth(t *testing.T) { 75 | type Node struct { 76 | Child *Node 77 | } 78 | n := &Node{} 79 | curr := n 80 | for range 20 { 81 | curr.Child = &Node{} 82 | curr = curr.Child 83 | } 84 | out := stripANSI(DumpStr(n)) 85 | assert.Contains(t, out, "... (max depth)") 86 | } 87 | 88 | func TestMapOutput(t *testing.T) { 89 | m := map[string]int{"a": 1, "b": 2} 90 | out := stripANSI(DumpStr(m)) 91 | 92 | assert.Contains(t, out, "a => 1") 93 | assert.Contains(t, out, "b => 2") 94 | } 95 | 96 | func TestSliceOutput(t *testing.T) { 97 | s := []string{"one", "two"} 98 | out := stripANSI(DumpStr(s)) 99 | 100 | assert.Contains(t, out, "0 => \"one\"") 101 | assert.Contains(t, out, "1 => \"two\"") 102 | } 103 | 104 | func TestAnonymousStruct(t *testing.T) { 105 | out := stripANSI(DumpStr(struct{ ID int }{ID: 123})) 106 | 107 | assert.Contains(t, out, "+ID") 108 | assert.Contains(t, out, "123") 109 | } 110 | 111 | func TestEmbeddedAnonymousStruct(t *testing.T) { 112 | type Base struct { 113 | ID int 114 | } 115 | type Derived struct { 116 | Base 117 | Name string 118 | } 119 | 120 | out := stripANSI(DumpStr(Derived{Base: Base{ID: 456}, Name: "Test"})) 121 | 122 | assert.Contains(t, out, `#godump.Derived { 123 | +Base => #godump.Base { 124 | +ID => 456 125 | } 126 | +Name => "Test" 127 | }`) 128 | } 129 | 130 | func TestControlCharsEscaped(t *testing.T) { 131 | s := "line1\nline2\tok" 132 | out := stripANSI(DumpStr(s)) 133 | assert.Contains(t, out, `\n`) 134 | assert.Contains(t, out, `\t`) 135 | } 136 | 137 | func TestFuncPlaceholder(t *testing.T) { 138 | fn := func() {} 139 | out := stripANSI(DumpStr(fn)) 140 | assert.Contains(t, out, "func()") 141 | } 142 | 143 | func TestSpecialTypes(t *testing.T) { 144 | type Unsafe struct { 145 | Ptr unsafe.Pointer 146 | } 147 | out := stripANSI(DumpStr(Unsafe{})) 148 | assert.Contains(t, out, "unsafe.Pointer(") 149 | 150 | c := make(chan int) 151 | out = stripANSI(DumpStr(c)) 152 | assert.Contains(t, out, "chan") 153 | 154 | complexNum := complex(1.1, 2.2) 155 | out = stripANSI(DumpStr(complexNum)) 156 | assert.Contains(t, out, "(1.1+2.2i)") 157 | } 158 | 159 | func TestDd(t *testing.T) { 160 | called := false 161 | exitFunc = func(code int) { called = true } 162 | Dd("x") 163 | assert.True(t, called) 164 | } 165 | 166 | func TestDumpHTML(t *testing.T) { 167 | html := DumpHTML(map[string]string{"foo": "bar"}) 168 | assert.Contains(t, html, `<span style="color:`) 169 | assert.Contains(t, html, `foo`) 170 | assert.Contains(t, html, `bar`) 171 | } 172 | 173 | func TestForceExported(t *testing.T) { 174 | type hidden struct { 175 | private string 176 | } 177 | h := hidden{private: "shh"} 178 | v := reflect.ValueOf(&h).Elem().Field(0) // make addressable 179 | out := forceExported(v) 180 | assert.True(t, out.CanInterface()) 181 | assert.Equal(t, "shh", out.Interface()) 182 | } 183 | 184 | func TestDetectColorVariants(t *testing.T) { 185 | t.Run("no environment variables", func(t *testing.T) { 186 | assert.True(t, detectColor()) 187 | }) 188 | 189 | t.Run("forcing no color", func(t *testing.T) { 190 | t.Setenv("NO_COLOR", "1") 191 | assert.False(t, detectColor()) 192 | }) 193 | 194 | t.Run("forcing color", func(t *testing.T) { 195 | t.Setenv("FORCE_COLOR", "1") 196 | assert.True(t, detectColor()) 197 | }) 198 | } 199 | 200 | func TestHtmlColorizeUnknown(t *testing.T) { 201 | // Color not in htmlColorMap 202 | out := htmlColorize("\033[999m", "test") 203 | assert.Contains(t, out, `<span style="color:`) 204 | assert.Contains(t, out, "test") 205 | } 206 | 207 | func TestUnreadableFallback(t *testing.T) { 208 | var b strings.Builder 209 | tw := tabwriter.NewWriter(&b, 0, 0, 1, ' ', 0) 210 | 211 | var ch chan int // nil typed value, not interface 212 | rv := reflect.ValueOf(ch) 213 | 214 | defaultDumper.printValue(tw, rv, 0, map[uintptr]bool{}) 215 | tw.Flush() 216 | 217 | output := stripANSI(b.String()) 218 | assert.Contains(t, output, "(nil)") 219 | } 220 | 221 | func TestFindFirstNonInternalFrameFallback(t *testing.T) { 222 | // Trigger the fallback by skipping deeper 223 | file, line := newDumperT(t).findFirstNonInternalFrame(0) 224 | // We can't assert much here reliably, but calling it adds coverage 225 | assert.True(t, len(file) >= 0) 226 | assert.True(t, line >= 0) 227 | } 228 | 229 | func TestUnreadableFieldFallback(t *testing.T) { 230 | var v reflect.Value // zero Value, not valid 231 | var sb strings.Builder 232 | tw := tabwriter.NewWriter(&sb, 0, 0, 1, ' ', 0) 233 | 234 | defaultDumper.printValue(tw, v, 0, map[uintptr]bool{}) 235 | tw.Flush() 236 | 237 | out := stripANSI(sb.String()) 238 | assert.Contains(t, out, "<invalid>") 239 | } 240 | 241 | func TestTimeType(t *testing.T) { 242 | now := time.Now() 243 | out := stripANSI(DumpStr(now)) 244 | assert.Contains(t, out, "#time.Time") 245 | } 246 | 247 | func TestPrimitiveTypes(t *testing.T) { 248 | out := stripANSI(DumpStr( 249 | int8(1), 250 | int16(2), 251 | uint8(3), 252 | uint16(4), 253 | uintptr(5), 254 | float32(1.5), 255 | [2]int{6, 7}, 256 | any(42), 257 | )) 258 | 259 | assert.Contains(t, out, "1") // int8 260 | assert.Contains(t, out, "2") // int16 261 | assert.Contains(t, out, "3") // uint8 262 | assert.Contains(t, out, "4") // uint16 263 | assert.Contains(t, out, "5") // uintptr 264 | assert.Contains(t, out, "1.500000") // float32 265 | assert.Contains(t, out, "0 =>") // array 266 | assert.Contains(t, out, "42") // interface{} 267 | } 268 | 269 | func TestEscapeControl_AllVariants(t *testing.T) { 270 | in := "\n\t\r\v\f\x1b" 271 | out := escapeControl(in) 272 | 273 | assert.Contains(t, out, `\n`) 274 | assert.Contains(t, out, `\t`) 275 | assert.Contains(t, out, `\r`) 276 | assert.Contains(t, out, `\v`) 277 | assert.Contains(t, out, `\f`) 278 | assert.Contains(t, out, `\x1b`) 279 | } 280 | 281 | func TestDefaultFallback_Unreadable(t *testing.T) { 282 | // Create a reflect.Value that is valid but not interfaceable 283 | var v reflect.Value 284 | 285 | var buf strings.Builder 286 | tw := tabwriter.NewWriter(&buf, 0, 0, 1, ' ', 0) 287 | defaultDumper.printValue(tw, v, 0, map[uintptr]bool{}) 288 | tw.Flush() 289 | 290 | assert.Contains(t, buf.String(), "<invalid>") 291 | } 292 | 293 | func TestPrintValue_Uintptr(t *testing.T) { 294 | // Use uintptr directly 295 | val := uintptr(12345) 296 | var buf strings.Builder 297 | tw := tabwriter.NewWriter(&buf, 0, 0, 1, ' ', 0) 298 | defaultDumper.printValue(tw, reflect.ValueOf(val), 0, map[uintptr]bool{}) 299 | tw.Flush() 300 | 301 | assert.Contains(t, buf.String(), "12345") 302 | } 303 | 304 | func TestPrintValue_UnsafePointer(t *testing.T) { 305 | // Trick it by converting an int pointer 306 | i := 5 307 | up := unsafe.Pointer(&i) 308 | var buf strings.Builder 309 | tw := tabwriter.NewWriter(&buf, 0, 0, 1, ' ', 0) 310 | defaultDumper.printValue(tw, reflect.ValueOf(up), 0, map[uintptr]bool{}) 311 | tw.Flush() 312 | 313 | assert.Contains(t, buf.String(), "unsafe.Pointer") 314 | } 315 | 316 | func TestPrintValue_Func(t *testing.T) { 317 | fn := func() {} 318 | var buf strings.Builder 319 | tw := tabwriter.NewWriter(&buf, 0, 0, 1, ' ', 0) 320 | defaultDumper.printValue(tw, reflect.ValueOf(fn), 0, map[uintptr]bool{}) 321 | tw.Flush() 322 | 323 | assert.Contains(t, buf.String(), "func()") 324 | } 325 | 326 | func TestMaxDepthTruncation(t *testing.T) { 327 | type Node struct { 328 | Next *Node 329 | } 330 | root := &Node{} 331 | curr := root 332 | for range 20 { 333 | curr.Next = &Node{} 334 | curr = curr.Next 335 | } 336 | 337 | out := stripANSI(DumpStr(root)) 338 | assert.Contains(t, out, "... (max depth)") 339 | } 340 | 341 | func TestCustomMaxDepthTruncation(t *testing.T) { 342 | type Node struct { 343 | Next *Node 344 | } 345 | root := &Node{} 346 | curr := root 347 | for range 3 { 348 | curr.Next = &Node{} 349 | curr = curr.Next 350 | } 351 | 352 | out := stripANSI(NewDumper(WithMaxDepth(2)).DumpStr(root)) 353 | assert.Contains(t, out, "... (max depth)") 354 | 355 | out = stripANSI(NewDumper(WithMaxDepth(0)).DumpStr(root)) 356 | assert.Contains(t, out, "... (max depth)") 357 | 358 | out = stripANSI(NewDumper(WithMaxDepth(-1)).DumpStr(root)) 359 | assert.NotContains(t, out, "... (max depth)") 360 | } 361 | 362 | func TestMapTruncation(t *testing.T) { 363 | largeMap := map[int]int{} 364 | for i := range 200 { 365 | largeMap[i] = i 366 | } 367 | out := stripANSI(DumpStr(largeMap)) 368 | assert.Contains(t, out, "... (truncated)") 369 | } 370 | 371 | func TestNilInterfaceTypePrint(t *testing.T) { 372 | var x any = (*int)(nil) 373 | out := stripANSI(DumpStr(x)) 374 | assert.Contains(t, out, "(nil)") 375 | } 376 | 377 | func TestUnreadableDefaultBranch(t *testing.T) { 378 | v := reflect.Value{} 379 | out := stripANSI(DumpStr(v)) 380 | assert.Contains(t, out, "#reflect.Value") // new expected fallback 381 | } 382 | 383 | func TestNilChan(t *testing.T) { 384 | var ch chan int 385 | out := DumpStr(ch) 386 | // Strip ANSI codes before checking 387 | clean := stripANSI(out) 388 | if !strings.Contains(clean, "chan int(nil)") { 389 | t.Errorf("Expected nil chan representation, got: %q", clean) 390 | } 391 | } 392 | 393 | func TestTruncatedSlice(t *testing.T) { 394 | slice := make([]int, 101) 395 | out := DumpStr(slice) 396 | if !strings.Contains(out, "... (truncated)") { 397 | t.Error("Expected slice to be truncated") 398 | } 399 | } 400 | 401 | func TestCustomTruncatedSlice(t *testing.T) { 402 | slice := make([]int, 3) 403 | out := NewDumper(WithMaxItems(2)).DumpStr(slice) 404 | if !strings.Contains(out, "... (truncated)") { 405 | t.Error("Expected slice to be truncated") 406 | } 407 | 408 | out = NewDumper(WithMaxItems(0)).DumpStr(slice) 409 | if !strings.Contains(out, "... (truncated)") { 410 | t.Error("Expected slice to be truncated") 411 | } 412 | 413 | out = NewDumper(WithMaxItems(-1)).DumpStr(slice) 414 | if strings.Contains(out, "... (truncated)") { 415 | t.Error("Negative MaxItems option should not be applied") 416 | } 417 | } 418 | 419 | func TestTruncatedString(t *testing.T) { 420 | s := strings.Repeat("x", 100001) 421 | out := DumpStr(s) 422 | if !strings.Contains(out, "…") { 423 | t.Error("Expected long string to be truncated") 424 | } 425 | } 426 | 427 | func TestCustomTruncatedString(t *testing.T) { 428 | s := strings.Repeat("x", 10) 429 | out := NewDumper(WithMaxStringLen(9)).DumpStr(s) 430 | if !strings.Contains(out, "…") { 431 | t.Error("Expected long string to be truncated") 432 | } 433 | 434 | out = NewDumper(WithMaxStringLen(0)).DumpStr(s) 435 | if !strings.Contains(out, "…") { 436 | t.Error("Expected long string to be truncated") 437 | } 438 | 439 | out = NewDumper(WithMaxStringLen(-1)).DumpStr(s) 440 | if strings.Contains(out, "…") { 441 | t.Error("Negative MaxStringLen option should not be applied") 442 | } 443 | } 444 | 445 | func TestBoolValues(t *testing.T) { 446 | out := DumpStr(true, false) 447 | if !strings.Contains(out, "true") || !strings.Contains(out, "false") { 448 | t.Error("Expected bools to be printed") 449 | } 450 | } 451 | 452 | func TestDefaultBranchFallback(t *testing.T) { 453 | var v reflect.Value // zero reflect.Value 454 | var sb strings.Builder 455 | tw := tabwriter.NewWriter(&sb, 0, 0, 1, ' ', 0) 456 | defaultDumper.printValue(tw, v, 0, map[uintptr]bool{}) 457 | tw.Flush() 458 | if !strings.Contains(sb.String(), "<invalid>") { 459 | t.Error("Expected default fallback for invalid reflect.Value") 460 | } 461 | } 462 | 463 | type BadStringer struct{} 464 | 465 | func (b *BadStringer) String() string { 466 | return "should never be called on nil" 467 | } 468 | 469 | func TestSafeStringerCall(t *testing.T) { 470 | var s fmt.Stringer = (*BadStringer)(nil) // nil pointer implementing Stringer 471 | 472 | out := stripANSI(DumpStr(s)) 473 | 474 | assert.Contains(t, out, "(nil)") 475 | assert.NotContains(t, out, "should never be called") // ensure String() wasn't called 476 | } 477 | 478 | func TestTimePointersEqual(t *testing.T) { 479 | now := time.Now() 480 | later := now.Add(time.Hour) 481 | 482 | type testCase struct { 483 | name string 484 | a *time.Time 485 | b *time.Time 486 | expected bool 487 | } 488 | 489 | tests := []testCase{ 490 | { 491 | name: "both nil", 492 | a: nil, 493 | b: nil, 494 | expected: true, 495 | }, 496 | { 497 | name: "one nil", 498 | a: &now, 499 | b: nil, 500 | expected: false, 501 | }, 502 | { 503 | name: "equal times", 504 | a: &now, 505 | b: &now, 506 | expected: true, 507 | }, 508 | { 509 | name: "different times", 510 | a: &now, 511 | b: &later, 512 | expected: false, 513 | }, 514 | } 515 | 516 | for _, tt := range tests { 517 | t.Run(tt.name, func(t *testing.T) { 518 | equal := timePtrsEqual(tt.a, tt.b) 519 | assert.Equal(t, tt.expected, equal) 520 | Dump(tt) 521 | }) 522 | } 523 | } 524 | 525 | func timePtrsEqual(a, b *time.Time) bool { 526 | if a == nil && b == nil { 527 | return true 528 | } 529 | if a == nil || b == nil { 530 | return false 531 | } 532 | return a.Equal(*b) 533 | } 534 | 535 | func TestPanicOnVisibleFieldsIndexMismatch(t *testing.T) { 536 | type Embedded struct { 537 | Secret string 538 | } 539 | type Outer struct { 540 | Embedded // Promoted field 541 | Age int 542 | } 543 | 544 | // This will panic with: 545 | // panic: reflect: Field index out of bounds 546 | _ = DumpStr(Outer{ 547 | Embedded: Embedded{Secret: "classified"}, 548 | Age: 42, 549 | }) 550 | } 551 | 552 | type FriendlyDuration time.Duration 553 | 554 | func (fd FriendlyDuration) String() string { 555 | td := time.Duration(fd) 556 | return fmt.Sprintf("%02d:%02d:%02d", int(td.Hours()), int(td.Minutes())%60, int(td.Seconds())%60) 557 | } 558 | 559 | func TestTheKitchenSink(t *testing.T) { 560 | type Inner struct { 561 | ID int 562 | Notes []string 563 | Blob []byte 564 | } 565 | 566 | type Ref struct { 567 | Self *Ref 568 | } 569 | 570 | type Everything struct { 571 | String string 572 | Bool bool 573 | Int int 574 | Float float64 575 | Time time.Time 576 | Duration time.Duration 577 | Friendly FriendlyDuration 578 | PtrString *string 579 | PtrDuration *time.Duration 580 | SliceInts []int 581 | ArrayStrings [2]string 582 | MapValues map[string]int 583 | Nested Inner 584 | NestedPtr *Inner 585 | Interface any 586 | Recursive *Ref 587 | privateField string 588 | privateStruct Inner 589 | } 590 | 591 | now := time.Now() 592 | ptrStr := "Hello" 593 | dur := time.Minute * 20 594 | 595 | val := Everything{ 596 | String: "test", 597 | Bool: true, 598 | Int: 42, 599 | Float: 3.1415, 600 | Time: now, 601 | Duration: dur, 602 | Friendly: FriendlyDuration(dur), 603 | PtrString: &ptrStr, 604 | PtrDuration: &dur, 605 | SliceInts: []int{1, 2, 3}, 606 | ArrayStrings: [2]string{"foo", "bar"}, 607 | MapValues: map[string]int{"a": 1, "b": 2}, 608 | Nested: Inner{ 609 | ID: 10, 610 | Notes: []string{"alpha", "beta"}, 611 | Blob: []byte(`{"kind":"test","ok":true}`), 612 | }, 613 | NestedPtr: &Inner{ 614 | ID: 99, 615 | Notes: []string{"x", "y"}, 616 | Blob: []byte(`{"msg":"hi","status":"cool"}`), 617 | }, 618 | Interface: map[string]bool{"ok": true}, 619 | Recursive: &Ref{}, 620 | privateField: "should show", 621 | privateStruct: Inner{ID: 5, Notes: []string{"private"}}, 622 | } 623 | val.Recursive.Self = val.Recursive // cycle 624 | 625 | Dump(val) 626 | 627 | out := stripANSI(DumpStr(val)) 628 | 629 | // Minimal coverage assertions 630 | assert.Contains(t, out, "+String") 631 | assert.Contains(t, out, `"test"`) 632 | assert.Contains(t, out, "+Bool") 633 | assert.Contains(t, out, "true") 634 | assert.Contains(t, out, "+Int") 635 | assert.Contains(t, out, "42") 636 | assert.Contains(t, out, "+Float") 637 | assert.Contains(t, out, "3.1415") 638 | assert.Contains(t, out, "+PtrString") 639 | assert.Contains(t, out, `"Hello"`) 640 | assert.Contains(t, out, "+SliceInts") 641 | assert.Contains(t, out, "0 => 1") 642 | assert.Contains(t, out, "+ArrayStrings") 643 | assert.Contains(t, out, `"foo"`) 644 | assert.Contains(t, out, "+MapValues") 645 | assert.Contains(t, out, "a => 1") 646 | assert.Contains(t, out, "+Nested") 647 | assert.Contains(t, out, "+ID") // from nested 648 | assert.Contains(t, out, "+Notes") 649 | assert.Contains(t, out, "-privateField") 650 | assert.Contains(t, out, `"should show"`) 651 | assert.Contains(t, out, "↩︎") // recursion reference 652 | 653 | // Ensure no panic occurred and a sane dump was produced 654 | assert.Contains(t, out, "#") // loosest 655 | assert.Contains(t, out, "Everything") // middle-ground 656 | 657 | } 658 | 659 | func TestAnsiColorize_Disabled(t *testing.T) { 660 | orig := enableColor 661 | enableColor = false 662 | defer func() { enableColor = orig }() 663 | 664 | out := ansiColorize(colorYellow, "test") 665 | assert.Equal(t, "test", out) 666 | } 667 | 668 | func TestForceExportedFallback(t *testing.T) { 669 | type s struct{ val string } 670 | v := reflect.ValueOf(s{"hidden"}).Field(0) // not addressable 671 | out := forceExported(v) 672 | assert.Equal(t, "hidden", out.String()) 673 | } 674 | 675 | func TestFindFirstNonInternalFrame_FallbackBranch(t *testing.T) { 676 | testDumper := newDumperT(t) 677 | // Always fail to simulate 10 bad frames 678 | testDumper.callerFn = func(int) (uintptr, string, int, bool) { 679 | return 0, "", 0, false 680 | } 681 | 682 | file, line := testDumper.findFirstNonInternalFrame(0) 683 | assert.Equal(t, "", file) 684 | assert.Equal(t, 0, line) 685 | } 686 | 687 | func TestForceExported_NoInterfaceNoAddr(t *testing.T) { 688 | v := reflect.ValueOf(struct{ a string }{"x"}).Field(0) 689 | if v.CanAddr() { 690 | t.Skip("Field unexpectedly addressable; cannot hit fallback branch") 691 | } 692 | out := forceExported(v) 693 | assert.Equal(t, "x", out.String()) 694 | } 695 | 696 | func TestPrintDumpHeader_SkipWhenNoFrame(t *testing.T) { 697 | testDumper := newDumperT(t) 698 | testDumper.callerFn = func(int) (uintptr, string, int, bool) { 699 | return 0, "", 0, false 700 | } 701 | 702 | var b strings.Builder 703 | testDumper.printDumpHeader(&b) 704 | assert.Equal(t, "", b.String()) // nothing should be written 705 | } 706 | 707 | type customChan chan int 708 | 709 | func TestPrintValue_ChanNilBranch_Hardforce(t *testing.T) { 710 | var buf strings.Builder 711 | tw := tabwriter.NewWriter(&buf, 0, 0, 1, ' ', 0) 712 | 713 | var ch customChan 714 | v := reflect.ValueOf(ch) 715 | 716 | assert.True(t, v.IsNil()) 717 | assert.Equal(t, reflect.Chan, v.Kind()) 718 | 719 | defaultDumper.printValue(tw, v, 0, map[uintptr]bool{}) 720 | tw.Flush() 721 | 722 | out := stripANSI(buf.String()) 723 | assert.Contains(t, out, "customChan(nil)") 724 | } 725 | 726 | type secretString string 727 | 728 | func (s secretString) String() string { 729 | return "👻 hidden stringer" 730 | } 731 | 732 | type hidden struct { 733 | secret secretString // unexported 734 | } 735 | 736 | func TestAsStringer_ForceExported(t *testing.T) { 737 | h := &hidden{secret: "boo"} // pointer makes fields addressable 738 | v := reflect.ValueOf(h).Elem().FieldByName("secret") // now v.CanAddr() is true, but v.CanInterface() is false 739 | 740 | assert.False(t, v.CanInterface(), "field must not be interfaceable") 741 | str := asStringer(v) 742 | 743 | assert.Contains(t, str, "👻 hidden stringer") 744 | } 745 | 746 | func TestForceExported_Interfaceable(t *testing.T) { 747 | v := reflect.ValueOf("already ok") 748 | require.True(t, v.CanInterface()) 749 | 750 | out := forceExported(v) 751 | 752 | assert.Equal(t, "already ok", out.Interface()) 753 | } 754 | 755 | func TestMakeAddressable_CanAddr(t *testing.T) { 756 | s := "hello" 757 | v := reflect.ValueOf(&s).Elem() // addressable string 758 | 759 | require.True(t, v.CanAddr()) 760 | 761 | out := makeAddressable(v) 762 | 763 | assert.Equal(t, v.Interface(), out.Interface()) // compare by value 764 | } 765 | 766 | func TestFdump_WritesToWriter(t *testing.T) { 767 | var buf strings.Builder 768 | 769 | type Inner struct { 770 | Field string 771 | } 772 | type Outer struct { 773 | InnerField Inner 774 | Number int 775 | } 776 | 777 | val := Outer{ 778 | InnerField: Inner{Field: "hello"}, 779 | Number: 42, 780 | } 781 | 782 | Fdump(&buf, val) 783 | 784 | out := buf.String() 785 | 786 | if !strings.Contains(out, "Outer") { 787 | t.Errorf("expected output to contain type name 'Outer', got: %s", out) 788 | } 789 | if !strings.Contains(out, "InnerField") || !strings.Contains(out, "hello") { 790 | t.Errorf("expected nested struct and field to appear, got: %s", out) 791 | } 792 | if !strings.Contains(out, "Number") || !strings.Contains(out, "42") { 793 | t.Errorf("expected field 'Number' with value '42', got: %s", out) 794 | } 795 | if !strings.Contains(out, "<#dump //") { 796 | t.Errorf("expected dump header with file and line, got: %s", out) 797 | } 798 | } 799 | 800 | func TestDumpWithCustomWriter(t *testing.T) { 801 | var buf strings.Builder 802 | 803 | type Inner struct { 804 | Field string 805 | } 806 | type Outer struct { 807 | InnerField Inner 808 | Number int 809 | } 810 | 811 | val := Outer{ 812 | InnerField: Inner{Field: "hello"}, 813 | Number: 42, 814 | } 815 | 816 | NewDumper(WithWriter(&buf)).Dump(val) 817 | 818 | out := buf.String() 819 | 820 | if !strings.Contains(out, "Outer") { 821 | t.Errorf("expected output to contain type name 'Outer', got: %s", out) 822 | } 823 | if !strings.Contains(out, "InnerField") || !strings.Contains(out, "hello") { 824 | t.Errorf("expected nested struct and field to appear, got: %s", out) 825 | } 826 | if !strings.Contains(out, "Number") || !strings.Contains(out, "42") { 827 | t.Errorf("expected field 'Number' with value '42', got: %s", out) 828 | } 829 | if !strings.Contains(out, "<#dump //") { 830 | t.Errorf("expected dump header with file and line, got: %s", out) 831 | } 832 | } 833 | 834 | func wrappedDumpStr(skip int, v any) string { 835 | return NewDumper(WithSkipStackFrames(skip)).DumpStr(v) 836 | } 837 | 838 | func TestDumpWithCustomSkipStackFrames(t *testing.T) { 839 | // caller stack frames are 840 | // 1 godump.go github.com/goforj/godump.findFirstNonInternalFrame skip by initialCallerSkip 841 | // 2 godump.go github.com/goforj/godump.printDumpHeader skip by initialCallerSkip 842 | // 3 godump.go github.com/goforj/godump.(*Dumper).DumpStr skip by fail names contain godump.go 843 | // 4 godump_test.go github.com/goforj/godump.TestDumpWithCustomSkipStackFrames 844 | // 5 testing.go testing.tRunner 845 | out := NewDumper().DumpStr("test") 846 | assert.Contains(t, out, "godump_test.go") 847 | 848 | out = NewDumper(WithSkipStackFrames(1)).DumpStr("test") 849 | assert.NotContains(t, out, "godump_test.go") 850 | 851 | // skip=0: should print the original DumpStr call site 852 | out = wrappedDumpStr(0, "test") 853 | assert.Contains(t, out, "godump_test.go") 854 | 855 | // skip=1: should print the location inside wrappedDumpStr 856 | out = wrappedDumpStr(1, "test") 857 | assert.Contains(t, out, "godump_test.go") 858 | 859 | // skip=2: should skip current file and show the outermost frame 860 | out = wrappedDumpStr(2, "test") 861 | assert.NotContains(t, out, "godump_test.go") 862 | } 863 | 864 | // TestHexDumpRendering checks that the hex dump output is rendered correctly. 865 | func TestHexDumpRendering(t *testing.T) { 866 | input := []byte(`{"error":"kek","last_error":"not implemented","lol":"ok"}`) 867 | output := DumpStr(input) 868 | output = stripANSI(output) 869 | Dump(input) 870 | 871 | if !strings.Contains(output, "7b 22 65 72 72 6f 72") { 872 | t.Error("expected hex dump output missing") 873 | } 874 | if !strings.Contains(output, "| {") { 875 | t.Error("ASCII preview opening missing") 876 | } 877 | if !strings.Contains(output, `"ok"`) { 878 | t.Error("ASCII preview end content missing") 879 | } 880 | if !strings.Contains(output, "([]uint8) (len=") { 881 | t.Error("missing []uint8 preamble") 882 | } 883 | } 884 | 885 | func TestDumpRawMessage(t *testing.T) { 886 | type Payload struct { 887 | Meta json.RawMessage 888 | } 889 | 890 | raw := json.RawMessage(`{"key":"value","flag":true}`) 891 | p := Payload{Meta: raw} 892 | 893 | Dump(p) 894 | } 895 | 896 | func TestDumpParagraphAsBytes(t *testing.T) { 897 | paragraph := `This is a sample paragraph of text. 898 | It contains multiple lines and some special characters like !@#$%^&*(). 899 | We want to see how it looks when dumped as a byte slice (hex dump). 900 | New lines are also important to check.` 901 | 902 | // Convert the string to a byte slice 903 | paragraphBytes := []byte(paragraph) 904 | 905 | Dump(paragraphBytes) 906 | } 907 | 908 | func TestIndirectionNilPointer(t *testing.T) { 909 | type Embedded struct { 910 | Surname string 911 | } 912 | 913 | type Test struct { 914 | Name string 915 | *Embedded 916 | } 917 | 918 | ts := &Test{ 919 | Name: "John", 920 | Embedded: nil, 921 | } 922 | 923 | Dump(ts) 924 | 925 | // assert that we don't panic or crash when dereferencing nil pointers 926 | if ts.Embedded != nil { 927 | t.Errorf("Expected Embedded to be nil, got: %+v", ts.Embedded) 928 | } 929 | 930 | // Check that the output does not contain dereferenced nil pointer 931 | out := stripANSI(DumpStr(ts)) 932 | assert.Contains(t, out, "+Name") 933 | assert.Contains(t, out, "John") 934 | assert.Contains(t, out, "+Embedded => *godump.Embedded(nil)") 935 | } 936 | 937 | func TestDumpJSON(t *testing.T) { 938 | t.Run("no arguments", func(t *testing.T) { 939 | jsonStr := DumpJSONStr() 940 | expected := `{"error": "DumpJSON called with no arguments"}` 941 | assert.JSONEq(t, expected, jsonStr) 942 | }) 943 | 944 | t.Run("single struct", func(t *testing.T) { 945 | type User struct { 946 | Name string `json:"name"` 947 | Age int `json:"age"` 948 | } 949 | user := User{Name: "Alice", Age: 30} 950 | jsonStr := DumpJSONStr(user) 951 | 952 | expected := `{ 953 | "name": "Alice", 954 | "age": 30 955 | }` 956 | assert.JSONEq(t, expected, jsonStr) 957 | }) 958 | 959 | t.Run("multiple values", func(t *testing.T) { 960 | jsonStr := DumpJSONStr("hello", 42, true) 961 | expected := `["hello", 42, true]` 962 | assert.JSONEq(t, expected, jsonStr) 963 | }) 964 | 965 | t.Run("unmarshallable type", func(t *testing.T) { 966 | ch := make(chan int) 967 | jsonStr := DumpJSONStr(ch) 968 | expected := `{"error": "json: unsupported type: chan int"}` 969 | assert.JSONEq(t, expected, jsonStr) 970 | }) 971 | 972 | t.Run("nil value", func(t *testing.T) { 973 | jsonStr := DumpJSONStr(nil) 974 | assert.JSONEq(t, "null", jsonStr) 975 | }) 976 | 977 | t.Run("multiple integers", func(t *testing.T) { 978 | jsonStr := DumpJSONStr(1, 2) 979 | assert.JSONEq(t, "[1, 2]", jsonStr) 980 | }) 981 | 982 | t.Run("slice of integers", func(t *testing.T) { 983 | jsonStr := DumpJSONStr([]int{1, 2}) 984 | assert.JSONEq(t, "[1, 2]", jsonStr) 985 | }) 986 | 987 | t.Run("Dumper.DumpJSON writes to writer", func(t *testing.T) { 988 | var buf bytes.Buffer 989 | d := NewDumper(WithWriter(&buf)) 990 | d.DumpJSON(map[string]int{"x": 1}) 991 | assert.JSONEq(t, `{"x": 1}`, buf.String()) 992 | }) 993 | 994 | t.Run("DumpJSON prints to stdout", func(t *testing.T) { 995 | r, w, _ := os.Pipe() 996 | done := make(chan struct{}) 997 | 998 | go func() { 999 | NewDumper(WithWriter(w)).DumpJSON("hello") 1000 | w.Close() 1001 | close(done) 1002 | }() 1003 | 1004 | output, _ := io.ReadAll(r) 1005 | <-done 1006 | 1007 | assert.JSONEq(t, `"hello"`, strings.TrimSpace(string(output))) 1008 | }) 1009 | 1010 | t.Run("DumpJSON prints valid JSON to stdout for multiple values (Dumper)", func(t *testing.T) { 1011 | var buf bytes.Buffer 1012 | 1013 | // Use WithWriter to inject the custom output 1014 | d := NewDumper(WithWriter(&buf)) 1015 | d.DumpJSON("foo", 123, true) 1016 | 1017 | var got []any 1018 | err := json.Unmarshal(buf.Bytes(), &got) 1019 | require.NoError(t, err) 1020 | assert.Equal(t, []any{"foo", float64(123), true}, got) 1021 | }) 1022 | 1023 | t.Run("DumpJSON prints valid JSON to stdout for multiple values", func(t *testing.T) { 1024 | // Save and override defaultDumper temporarily 1025 | orig := defaultDumper 1026 | 1027 | r, w, _ := os.Pipe() 1028 | defaultDumper = NewDumper(WithWriter(w)) 1029 | 1030 | // Read from pipe in goroutine 1031 | done := make(chan string) 1032 | go func() { 1033 | var buf bytes.Buffer 1034 | _, _ = io.Copy(&buf, r) 1035 | done <- buf.String() 1036 | }() 1037 | 1038 | // Perform the dump 1039 | DumpJSON("foo", 123, true) 1040 | 1041 | _ = w.Close() 1042 | defaultDumper = orig // restore original dumper 1043 | 1044 | output := <-done 1045 | output = strings.TrimSpace(output) 1046 | 1047 | t.Logf("Captured: %q", output) 1048 | 1049 | var got []any 1050 | err := json.Unmarshal([]byte(output), &got) 1051 | require.NoError(t, err, "json.Unmarshal failed with output: %q", output) 1052 | 1053 | assert.Equal(t, []any{"foo", float64(123), true}, got) 1054 | }) 1055 | 1056 | } 1057 | --------------------------------------------------------------------------------