The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .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 | 


--------------------------------------------------------------------------------