Skip to content

Commit b8b3baf

Browse files
committed
Initial commit
0 parents  commit b8b3baf

8 files changed

+385
-0
lines changed

.gitattributes

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Auto detect text files and perform LF normalization
2+
* text=auto

.gitignore

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# If you prefer the allow list template instead of the deny list, see community template:
2+
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
3+
#
4+
# Binaries for programs and plugins
5+
*.exe
6+
*.exe~
7+
*.dll
8+
*.so
9+
*.dylib
10+
11+
# Test binary, built with `go test -c`
12+
*.test
13+
14+
# Output of the go coverage tool, specifically when used with LiteIDE
15+
*.out
16+
17+
# Dependency directories (remove the comment below to include it)
18+
# vendor/
19+
20+
# Go workspace file
21+
go.work
22+
23+
# misc
24+
/main_test.go

LICENSE

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Copyright 2024 kociumba
2+
3+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4+
5+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6+
7+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# stackparse
2+
utility to parse go stack traces and make them more readable

Taskfile.yml

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# https://taskfile.dev
2+
3+
version: '3'
4+
5+
tasks:
6+
publish:
7+
vars:
8+
GOPROXY: proxy.golang.org
9+
cmds:
10+
- go mod tidy
11+
- git tag {{.V}}
12+
- git push origin {{.V}}
13+
- go list -m github.com/kociumba/stackparse@{{.V}}

go.mod

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module github.com/kociumba/stackparse
2+
3+
go 1.23.0
4+
5+
require github.com/charmbracelet/lipgloss v0.13.1
6+
7+
require (
8+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
9+
github.com/charmbracelet/x/ansi v0.3.2 // indirect
10+
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
11+
github.com/mattn/go-isatty v0.0.20 // indirect
12+
github.com/mattn/go-runewidth v0.0.15 // indirect
13+
github.com/muesli/termenv v0.15.2 // indirect
14+
github.com/rivo/uniseg v0.4.7 // indirect
15+
golang.org/x/sys v0.19.0 // indirect
16+
)

go.sum

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
2+
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
3+
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
4+
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
5+
github.com/charmbracelet/lipgloss v0.13.1 h1:Oik/oqDTMVA01GetT4JdEC033dNzWoQHdWnHnQmXE2A=
6+
github.com/charmbracelet/lipgloss v0.13.1/go.mod h1:zaYVJ2xKSKEnTEEbX6uAHabh2d975RJ+0yfkFpRBz5U=
7+
github.com/charmbracelet/x/ansi v0.3.2 h1:wsEwgAN+C9U06l9dCVMX0/L3x7ptvY1qmjMwyfE6USY=
8+
github.com/charmbracelet/x/ansi v0.3.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
9+
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
10+
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
11+
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
12+
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
13+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
14+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
15+
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
16+
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
17+
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
18+
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
19+
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
20+
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
21+
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
22+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
23+
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
24+
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

main.go

+297
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
// utility to parse go stack traces and make them more readable
2+
package stackparse
3+
4+
import (
5+
"fmt"
6+
"path/filepath"
7+
"regexp"
8+
"strings"
9+
10+
"github.com/charmbracelet/lipgloss"
11+
"github.com/charmbracelet/lipgloss/tree"
12+
)
13+
14+
type stackEntry struct {
15+
functionName string
16+
args string
17+
file string
18+
line string
19+
isCreatedBy bool
20+
}
21+
22+
type Option func(*config)
23+
24+
// WithColorize returns an Option that enables colorizing of the stack trace output using ANSI escape codes.
25+
func Color(opt bool) Option {
26+
return func(cfg *config) {
27+
cfg.colorize = opt
28+
}
29+
}
30+
31+
// do not color the output, usefull if piping to another program or a file
32+
func NoColor() Option {
33+
return Color(false)
34+
}
35+
36+
// WithSimple returns an Option that enables simplified output.
37+
func Simple(opt bool) Option {
38+
return func(cfg *config) {
39+
cfg.simple = opt
40+
}
41+
}
42+
43+
// print full filepaths and extra info
44+
func NoSimple() Option {
45+
return Simple(false)
46+
}
47+
48+
type config struct {
49+
colorize bool // should the output be colorized with ansi escape codes
50+
simple bool // should the output be simplified by omitting certain details
51+
}
52+
53+
var (
54+
baseStyle lipgloss.Style
55+
goroutineStyle lipgloss.Style
56+
functionStyle lipgloss.Style
57+
argsStyle lipgloss.Style
58+
fileStyle lipgloss.Style
59+
lineStyle lipgloss.Style
60+
createdByStyle lipgloss.Style
61+
repeatStyle lipgloss.Style
62+
)
63+
64+
// Style definitions
65+
func initStyles() {
66+
// Base styles
67+
baseStyle = lipgloss.NewStyle().
68+
PaddingLeft(2)
69+
70+
// Goroutine style
71+
goroutineStyle = lipgloss.NewStyle().
72+
Bold(true).
73+
Foreground(lipgloss.Color("#00ADD8")). // Go blue
74+
MarginTop(1).
75+
MarginBottom(1)
76+
77+
// Function style
78+
functionStyle = lipgloss.NewStyle().
79+
Foreground(lipgloss.Color("#98C379")) // Soft green
80+
// PaddingLeft(4)
81+
82+
// Args style
83+
argsStyle = lipgloss.NewStyle().
84+
Foreground(lipgloss.Color("#61AFEF")). // Light blue
85+
PaddingLeft(2)
86+
87+
// File style
88+
fileStyle = lipgloss.NewStyle().
89+
Foreground(lipgloss.Color("#C678DD")) // Purple
90+
// PaddingLeft(4)
91+
92+
// Line number style
93+
lineStyle = lipgloss.NewStyle().
94+
Foreground(lipgloss.Color("#E5C07B")). // Gold
95+
PaddingLeft(2)
96+
97+
// Created by style
98+
createdByStyle = lipgloss.NewStyle().
99+
Foreground(lipgloss.Color("#E06C75")) // Soft red
100+
// PaddingLeft(4)
101+
102+
// Repeat count style
103+
repeatStyle = lipgloss.NewStyle().
104+
Foreground(lipgloss.Color("#E06C75")). // Soft red
105+
Italic(true)
106+
}
107+
108+
// Regular expressions for different parts of the stack trace
109+
var (
110+
// Matches goroutine header line
111+
goroutineRegex = regexp.MustCompile(`goroutine (\d+) \[([\w\.]+)\]:`)
112+
113+
// Matches function calls with arguments
114+
functionRegex = regexp.MustCompile(`^(\S+)\((.*)\)$`)
115+
116+
// Matches file location lines
117+
locationRegex = regexp.MustCompile(`^\s*(.+\.go):(\d+)(.*)$`)
118+
119+
// Matches "created by" lines
120+
createdByRegex = regexp.MustCompile(`created by (.+) in goroutine (\d+)`)
121+
)
122+
123+
// Parse is the main entry point for parsing stack traces
124+
func Parse(stack []byte, options ...Option) []byte {
125+
cfg := config{
126+
colorize: true,
127+
simple: true,
128+
}
129+
130+
for _, opt := range options {
131+
opt(&cfg)
132+
}
133+
134+
initStyles()
135+
136+
lines := strings.Split(string(stack), "\n")
137+
return []byte(parseStackTrace(lines, cfg))
138+
}
139+
140+
// func formatStackLine(label, content string, style lipgloss.Style) string {
141+
// return style.Render(label) + content
142+
// }
143+
144+
func parseStackTrace(lines []string, cfg config) string {
145+
var result []string
146+
var entries []stackEntry
147+
var currentEntry *stackEntry
148+
functionCounts := make(map[string]int)
149+
150+
// reset all styles that have color
151+
if !cfg.colorize {
152+
goroutineStyle = lipgloss.NewStyle()
153+
functionStyle = lipgloss.NewStyle()
154+
argsStyle = lipgloss.NewStyle()
155+
fileStyle = lipgloss.NewStyle()
156+
lineStyle = lipgloss.NewStyle()
157+
createdByStyle = lipgloss.NewStyle()
158+
repeatStyle = lipgloss.NewStyle()
159+
}
160+
161+
// First pass: collect all entries
162+
for i := 0; i < len(lines); i++ {
163+
line := strings.TrimSpace(lines[i])
164+
if line == "" {
165+
continue
166+
}
167+
168+
// Handle goroutine header
169+
if match := goroutineRegex.FindStringSubmatch(line); match != nil {
170+
if currentEntry != nil {
171+
entries = append(entries, *currentEntry)
172+
currentEntry = nil
173+
}
174+
header := fmt.Sprintf("Goroutine %s: %s", match[1], match[2])
175+
result = append(result, goroutineStyle.Render(header))
176+
continue
177+
}
178+
179+
// Handle function calls
180+
if match := functionRegex.FindStringSubmatch(line); match != nil {
181+
if currentEntry != nil {
182+
entries = append(entries, *currentEntry)
183+
}
184+
currentEntry = &stackEntry{
185+
functionName: match[1],
186+
args: match[2],
187+
}
188+
functionCounts[currentEntry.functionName]++
189+
continue
190+
}
191+
192+
// Handle file locations
193+
if match := locationRegex.FindStringSubmatch(line); match != nil && currentEntry != nil {
194+
filePath := match[1]
195+
if cfg.simple {
196+
filePath = simplifyPath(filePath)
197+
}
198+
currentEntry.file = filePath
199+
currentEntry.line = match[2]
200+
continue
201+
}
202+
203+
// Handle "created by" lines
204+
if match := createdByRegex.FindStringSubmatch(line); match != nil {
205+
if currentEntry != nil {
206+
entries = append(entries, *currentEntry)
207+
}
208+
currentEntry = &stackEntry{
209+
functionName: match[1],
210+
isCreatedBy: true,
211+
}
212+
if i+1 < len(lines) && locationRegex.MatchString(lines[i+1]) {
213+
locMatch := locationRegex.FindStringSubmatch(lines[i+1])
214+
filePath := locMatch[1]
215+
if cfg.simple {
216+
filePath = simplifyPath(filePath)
217+
}
218+
currentEntry.file = filePath
219+
currentEntry.line = locMatch[2]
220+
i++
221+
}
222+
continue
223+
}
224+
}
225+
226+
if currentEntry != nil {
227+
entries = append(entries, *currentEntry)
228+
}
229+
230+
// Calculate maximum widths
231+
var maxFunctionWidth, maxFileWidth, maxLineWidth int
232+
for _, entry := range entries {
233+
maxFunctionWidth = max(maxFunctionWidth, lipgloss.Width(entry.functionName))
234+
maxFileWidth = max(maxFileWidth, lipgloss.Width(entry.file))
235+
maxLineWidth = max(maxLineWidth, lipgloss.Width(entry.line))
236+
}
237+
238+
// Create style for aligned content
239+
// alignedStyle := lipgloss.NewStyle().Width(maxFunctionWidth + maxFileWidth + maxLineWidth + 20)
240+
241+
// Second pass: format entries with tree structure
242+
var currentTree *tree.Tree
243+
for _, entry := range entries {
244+
if functionCounts[entry.functionName] <= 1 {
245+
// Create a tree for each function call
246+
if entry.isCreatedBy {
247+
currentTree = tree.New().Root(
248+
createdByStyle.Render("Created by: ") +
249+
entry.functionName,
250+
)
251+
} else {
252+
currentTree = tree.New().Root(
253+
functionStyle.Render("Function: ") +
254+
lipgloss.NewStyle().Width(maxFunctionWidth).Render(entry.functionName) +
255+
argsStyle.Render(fmt.Sprintf("(%s)", entry.args)),
256+
)
257+
}
258+
259+
// Add file location as a child of the function
260+
fileInfo := lipgloss.JoinHorizontal(
261+
lipgloss.Left,
262+
fileStyle.Render("At: "),
263+
lipgloss.NewStyle().Width(maxFileWidth).Render(entry.file),
264+
lineStyle.Render(fmt.Sprintf(" Line: %s", entry.line)),
265+
)
266+
currentTree.Child(fileInfo)
267+
268+
// Render the tree and add it to results
269+
result = append(result, currentTree.String())
270+
} else {
271+
// For repeated functions, add a counter
272+
repCount := repeatStyle.Render(fmt.Sprintf(" (repeated %d times)", functionCounts[entry.functionName]))
273+
currentTree = tree.New().Root(
274+
functionStyle.Render("Function: ") +
275+
lipgloss.NewStyle().Width(maxFunctionWidth).Render(entry.functionName) +
276+
argsStyle.Render(fmt.Sprintf("(%s)", entry.args)) +
277+
repCount,
278+
)
279+
result = append(result, currentTree.String())
280+
}
281+
}
282+
283+
initStyles()
284+
285+
return strings.Join(result, "\n")
286+
}
287+
288+
func max(a, b int) int {
289+
if a > b {
290+
return a
291+
}
292+
return b
293+
}
294+
295+
func simplifyPath(path string) string {
296+
return filepath.Base(path)
297+
}

0 commit comments

Comments
 (0)