tint.go

v0.2.0
Doc Versions Source
1
package scribe
2
3
import (
4
	"context"
5
	"fmt"
6
	"io"
7
	"log/slog"
8
	"runtime"
9
	"slices"
10
	"strconv"
11
	"sync"
12
13
	"go.bigb.es/auxilia/stacktrace"
14
)
15
16
// TintHandler is a pretty-printing slog.Handler for terminal output.
17
type TintHandler struct {
18
	mu             *sync.Mutex // shared mutex for clones
19
	writer         io.Writer
20
	level          slog.Leveler
21
	replaceAttr    func(groups []string, a slog.Attr) slog.Attr
22
	addSource      bool
23
	timeFormat     string
24
	noColor        bool
25
	levelColors    map[slog.Level]Color
26
	expandErrors   bool
27
	fullStacktrace bool
28
	maskRules      []MaskRule
29
30
	// State for WithAttrs/WithGroup
31
	preformatted []byte
32
	groups       []string
33
}
34
35
// NewTintHandler creates a new pretty-printing handler for terminal output.
36
func NewTintHandler(opts ...Option) *TintHandler {
37
	o := defaultOptions()
38
	for _, opt := range opts {
39
		if opt != nil {
40
			opt(o)
41
		}
42
	}
43
44
	return &TintHandler{
45
		mu:             &sync.Mutex{},
46
		writer:         o.writer,
47
		level:          o.level,
48
		replaceAttr:    o.replaceAttr,
49
		addSource:      o.addSource,
50
		timeFormat:     o.timeFormat,
51
		noColor:        o.noColor,
52
		levelColors:    o.levelColors,
53
		expandErrors:   o.expandErrors,
54
		fullStacktrace: o.fullStacktrace,
55
		maskRules:      o.maskRules,
56
	}
57
}
58
59
// Enabled implements slog.Handler.
60
func (h *TintHandler) Enabled(_ context.Context, level slog.Level) bool {
61
	return level >= h.level.Level()
62
}
63
64
// Handle implements slog.Handler.
65
func (h *TintHandler) Handle(_ context.Context, r slog.Record) error {
66
	buf := getBuffer()
67
	defer putBuffer(buf)
68
69
	h.format(buf, r)
70
71
	h.mu.Lock()
72
	defer h.mu.Unlock()
73
	_, err := h.writer.Write(*buf)
74
	return err
75
}
76
77
// WithAttrs implements slog.Handler.
78
func (h *TintHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
79
	if len(attrs) == 0 {
80
		return h
81
	}
82
83
	h2 := h.clone()
84
	buf := getBuffer()
85
	defer putBuffer(buf)
86
87
	for _, attr := range attrs {
88
		h2.formatAttr(buf, attr, h2.groups)
89
	}
90
91
	h2.preformatted = append(h2.preformatted, (*buf)...)
92
	return h2
93
}
94
95
// WithGroup implements slog.Handler.
96
func (h *TintHandler) WithGroup(name string) slog.Handler {
97
	if name == "" {
98
		return h
99
	}
100
101
	h2 := h.clone()
102
	h2.groups = append(h2.groups, name)
103
	return h2
104
}
105
106
func (h *TintHandler) clone() *TintHandler {
107
	return &TintHandler{
108
		mu:             h.mu, // shared mutex
109
		writer:         h.writer,
110
		level:          h.level,
111
		replaceAttr:    h.replaceAttr,
112
		addSource:      h.addSource,
113
		timeFormat:     h.timeFormat,
114
		noColor:        h.noColor,
115
		levelColors:    h.levelColors,
116
		expandErrors:   h.expandErrors,
117
		fullStacktrace: h.fullStacktrace,
118
		maskRules:      h.maskRules, // shared, immutable
119
		preformatted:   slices.Clone(h.preformatted),
120
		groups:         slices.Clone(h.groups),
121
	}
122
}
123
124
// format writes a formatted log record to buf.
125
func (h *TintHandler) format(buf *[]byte, r slog.Record) {
126
	// Time
127
	if !r.Time.IsZero() {
128
		h.appendTime(buf, r)
129
		*buf = append(*buf, ' ')
130
	}
131
132
	// Level
133
	h.appendLevel(buf, r.Level)
134
	*buf = append(*buf, ' ')
135
136
	// Source (if enabled)
137
	if h.addSource && r.PC != 0 {
138
		h.appendSource(buf, r.PC)
139
		*buf = append(*buf, ' ')
140
	}
141
142
	// Message
143
	*buf = append(*buf, r.Message...)
144
145
	// Preformatted attrs from WithAttrs
146
	*buf = append(*buf, h.preformatted...)
147
148
	// Record attrs
149
	r.Attrs(func(a slog.Attr) bool {
150
		h.formatAttr(buf, a, h.groups)
151
		return true
152
	})
153
154
	*buf = append(*buf, '\n')
155
}
156
157
func (h *TintHandler) appendTime(buf *[]byte, r slog.Record) {
158
	timeStr := r.Time.Format(h.timeFormat)
159
	if h.noColor {
160
		*buf = append(*buf, timeStr...)
161
	} else {
162
		*buf = append(*buf, Faint.String()...)
163
		*buf = append(*buf, timeStr...)
164
		*buf = append(*buf, Reset.String()...)
165
	}
166
}
167
168
func (h *TintHandler) appendLevel(buf *[]byte, level slog.Level) {
169
	levelStr := levelString(level)
170
171
	if h.noColor {
172
		*buf = append(*buf, levelStr...)
173
		return
174
	}
175
176
	color, ok := h.levelColors[level]
177
	if !ok {
178
		// Find the closest level
179
		for l := level; l >= slog.LevelDebug; l-- {
180
			if c, found := h.levelColors[l]; found {
181
				color = c
182
				ok = true
183
				break
184
			}
185
		}
186
		if !ok {
187
			color = Reset
188
		}
189
	}
190
191
	*buf = append(*buf, color.String()...)
192
	*buf = append(*buf, levelStr...)
193
	*buf = append(*buf, Reset.String()...)
194
}
195
196
func (h *TintHandler) appendSource(buf *[]byte, pc uintptr) {
197
	fs := runtime.CallersFrames([]uintptr{pc})
198
	f, _ := fs.Next()
199
	if f.File == "" {
200
		return
201
	}
202
203
	// Extract just the filename from the full path
204
	file := f.File
205
	for i := len(file) - 1; i >= 0; i-- {
206
		if file[i] == '/' {
207
			file = file[i+1:]
208
			break
209
		}
210
	}
211
212
	if h.noColor {
213
		*buf = append(*buf, file...)
214
		*buf = append(*buf, ':')
215
		*buf = strconv.AppendInt(*buf, int64(f.Line), 10)
216
	} else {
217
		*buf = append(*buf, Faint.String()...)
218
		*buf = append(*buf, file...)
219
		*buf = append(*buf, ':')
220
		*buf = strconv.AppendInt(*buf, int64(f.Line), 10)
221
		*buf = append(*buf, Reset.String()...)
222
	}
223
}
224
225
func (h *TintHandler) formatAttr(buf *[]byte, a slog.Attr, groups []string) {
226
	if h.replaceAttr != nil {
227
		a = h.replaceAttr(groups, a)
228
	}
229
230
	if a.Equal(slog.Attr{}) {
231
		return
232
	}
233
234
	// Build the base path from groups
235
	var path string
236
	for _, g := range groups {
237
		if path != "" {
238
			path += "."
239
		}
240
		path += g
241
	}
242
243
	// Check for errValue - get color hint
244
	var errColor Color
245
	if ev, ok := a.Value.Any().(errValue); ok && !h.noColor {
246
		errColor = ev.Color()
247
	}
248
249
	h.formatValueFlat(buf, a.Key, a.Value, path, errColor)
250
}
251
252
// formatValueFlat formats a value with dot notation for nested structures.
253
// path is the prefix (e.g., "request" for "request.id")
254
// errColor is set for error values to color the output
255
func (h *TintHandler) formatValueFlat(buf *[]byte, key string, v slog.Value, path string, errColor Color) {
256
	// Build full key path
257
	fullKey := key
258
	if path != "" {
259
		fullKey = path + "." + key
260
	}
261
262
	// Resolve LogValuer
263
	v = v.Resolve()
264
265
	// Special handling for stacktrace.Trace
266
	if trace, ok := v.Any().(stacktrace.Trace); ok {
267
		v = slog.StringValue(h.formatTrace(trace))
268
	}
269
270
	// Handle nested structures
271
	switch v.Kind() {
272
	case slog.KindGroup:
273
		attrs := v.Group()
274
		if len(attrs) == 0 {
275
			h.writeKeyValue(buf, fullKey, "{}", errColor)
276
			return
277
		}
278
		// Flatten: emit each attr with fullKey as prefix
279
		for _, attr := range attrs {
280
			h.formatValueFlat(buf, attr.Key, attr.Value, fullKey, errColor)
281
		}
282
		return
283
284
	case slog.KindAny:
285
		// Check for slices/arrays
286
		if h.formatSlice(buf, fullKey, v.Any(), errColor) {
287
			return
288
		}
289
	}
290
291
	// Scalar value - emit key=value
292
	h.writeKeyValue(buf, fullKey, h.valueToString(v), errColor)
293
}
294
295
// formatSlice handles slice/array formatting.
296
// Returns true if the value was a slice and was handled.
297
func (h *TintHandler) formatSlice(buf *[]byte, key string, val any, errColor Color) bool {
298
	switch s := val.(type) {
299
	case []any:
300
		if len(s) == 0 {
301
			h.writeKeyValue(buf, key, "[]", errColor)
302
			return true
303
		}
304
		if h.isScalarSlice(s) {
305
			h.writeKeyValue(buf, key, h.formatScalarSlice(s), errColor)
306
		} else {
307
			for i, item := range s {
308
				indexKey := fmt.Sprintf("%s[%d]", key, i)
309
				h.formatAnyValue(buf, indexKey, item, errColor)
310
			}
311
		}
312
		return true
313
314
	case []string:
315
		h.writeKeyValue(buf, key, h.formatStringSlice(s), errColor)
316
		return true
317
318
	case []int:
319
		h.writeKeyValue(buf, key, h.formatIntSlice(s), errColor)
320
		return true
321
322
	case []int64:
323
		h.writeKeyValue(buf, key, h.formatInt64Slice(s), errColor)
324
		return true
325
326
	case []float64:
327
		h.writeKeyValue(buf, key, h.formatFloat64Slice(s), errColor)
328
		return true
329
330
	case []bool:
331
		h.writeKeyValue(buf, key, h.formatBoolSlice(s), errColor)
332
		return true
333
334
	case []slog.Attr:
335
		if len(s) == 0 {
336
			h.writeKeyValue(buf, key, "[]", errColor)
337
			return true
338
		}
339
		for i, attr := range s {
340
			indexKey := fmt.Sprintf("%s[%d]", key, i)
341
			h.formatValueFlat(buf, attr.Key, attr.Value, indexKey, errColor)
342
		}
343
		return true
344
	}
345
346
	return false
347
}
348
349
// formatAnyValue formats an arbitrary value with flattening
350
func (h *TintHandler) formatAnyValue(buf *[]byte, key string, val any, errColor Color) {
351
	switch v := val.(type) {
352
	case map[string]any:
353
		for k, mv := range v {
354
			h.formatAnyValue(buf, key+"."+k, mv, errColor)
355
		}
356
	case []any:
357
		if h.isScalarSlice(v) {
358
			h.writeKeyValue(buf, key, h.formatScalarSlice(v), errColor)
359
		} else {
360
			for i, item := range v {
361
				h.formatAnyValue(buf, fmt.Sprintf("%s[%d]", key, i), item, errColor)
362
			}
363
		}
364
	default:
365
		h.writeKeyValue(buf, key, fmt.Sprint(val), errColor)
366
	}
367
}
368
369
// isScalarSlice checks if all elements are scalar types
370
func (h *TintHandler) isScalarSlice(s []any) bool {
371
	for _, v := range s {
372
		switch v.(type) {
373
		case string, int, int64, float64, bool, nil:
374
			continue
375
		default:
376
			return false
377
		}
378
	}
379
	return true
380
}
381
382
func (h *TintHandler) formatScalarSlice(s []any) string {
383
	var b []byte
384
	b = append(b, '[')
385
	for i, v := range s {
386
		if i > 0 {
387
			b = append(b, ", "...)
388
		}
389
		switch val := v.(type) {
390
		case string:
391
			if needsQuoting(val) {
392
				b = append(b, strconv.Quote(val)...)
393
			} else {
394
				b = append(b, val...)
395
			}
396
		default:
397
			b = append(b, fmt.Sprint(v)...)
398
		}
399
	}
400
	b = append(b, ']')
401
	return string(b)
402
}
403
404
func (h *TintHandler) formatStringSlice(s []string) string {
405
	var b []byte
406
	b = append(b, '[')
407
	for i, v := range s {
408
		if i > 0 {
409
			b = append(b, ", "...)
410
		}
411
		if needsQuoting(v) {
412
			b = append(b, strconv.Quote(v)...)
413
		} else {
414
			b = append(b, v...)
415
		}
416
	}
417
	b = append(b, ']')
418
	return string(b)
419
}
420
421
func (h *TintHandler) formatIntSlice(s []int) string {
422
	var b []byte
423
	b = append(b, '[')
424
	for i, v := range s {
425
		if i > 0 {
426
			b = append(b, ", "...)
427
		}
428
		b = strconv.AppendInt(b, int64(v), 10)
429
	}
430
	b = append(b, ']')
431
	return string(b)
432
}
433
434
func (h *TintHandler) formatInt64Slice(s []int64) string {
435
	var b []byte
436
	b = append(b, '[')
437
	for i, v := range s {
438
		if i > 0 {
439
			b = append(b, ", "...)
440
		}
441
		b = strconv.AppendInt(b, v, 10)
442
	}
443
	b = append(b, ']')
444
	return string(b)
445
}
446
447
func (h *TintHandler) formatFloat64Slice(s []float64) string {
448
	var b []byte
449
	b = append(b, '[')
450
	for i, v := range s {
451
		if i > 0 {
452
			b = append(b, ", "...)
453
		}
454
		b = strconv.AppendFloat(b, v, 'g', -1, 64)
455
	}
456
	b = append(b, ']')
457
	return string(b)
458
}
459
460
func (h *TintHandler) formatBoolSlice(s []bool) string {
461
	var b []byte
462
	b = append(b, '[')
463
	for i, v := range s {
464
		if i > 0 {
465
			b = append(b, ", "...)
466
		}
467
		b = strconv.AppendBool(b, v)
468
	}
469
	b = append(b, ']')
470
	return string(b)
471
}
472
473
// writeKeyValue writes " key=value" to buf with optional coloring
474
func (h *TintHandler) writeKeyValue(buf *[]byte, key, value string, errColor Color) {
475
	// Check for masking
476
	value = h.applyMask(key, value)
477
478
	*buf = append(*buf, ' ')
479
480
	if h.noColor {
481
		*buf = append(*buf, key...)
482
		*buf = append(*buf, '=')
483
		*buf = append(*buf, value...)
484
	} else {
485
		keyColor := Cyan
486
		if errColor != "" {
487
			keyColor = errColor
488
		}
489
		*buf = append(*buf, keyColor.String()...)
490
		*buf = append(*buf, key...)
491
		*buf = append(*buf, Reset.String()...)
492
		*buf = append(*buf, '=')
493
		if errColor != "" {
494
			*buf = append(*buf, errColor.String()...)
495
		}
496
		*buf = append(*buf, value...)
497
		if errColor != "" {
498
			*buf = append(*buf, Reset.String()...)
499
		}
500
	}
501
}
502
503
// applyMask checks if the key matches any mask rule and returns the masked value.
504
func (h *TintHandler) applyMask(key, value string) string {
505
	for _, rule := range h.maskRules {
506
		if rule.Pattern.MatchString(key) {
507
			if rule.KeepPrefix > 0 && len(value) > rule.KeepPrefix {
508
				return value[:rule.KeepPrefix] + rule.Replacement
509
			}
510
			return rule.Replacement
511
		}
512
	}
513
	return value
514
}
515
516
// valueToString converts a slog.Value to string representation
517
func (h *TintHandler) valueToString(v slog.Value) string {
518
	switch v.Kind() {
519
	case slog.KindString:
520
		s := v.String()
521
		if needsQuoting(s) {
522
			return strconv.Quote(s)
523
		}
524
		return s
525
	case slog.KindInt64:
526
		return strconv.FormatInt(v.Int64(), 10)
527
	case slog.KindUint64:
528
		return strconv.FormatUint(v.Uint64(), 10)
529
	case slog.KindFloat64:
530
		return strconv.FormatFloat(v.Float64(), 'g', -1, 64)
531
	case slog.KindBool:
532
		return strconv.FormatBool(v.Bool())
533
	case slog.KindDuration:
534
		return v.Duration().String()
535
	case slog.KindTime:
536
		return v.Time().Format(h.timeFormat)
537
	case slog.KindAny:
538
		return fmt.Sprint(v.Any())
539
	default:
540
		return v.String()
541
	}
542
}
543
544
// formatTrace formats a stacktrace according to fullStacktrace setting.
545
func (h *TintHandler) formatTrace(trace stacktrace.Trace) string {
546
	if len(trace) == 0 {
547
		return ""
548
	}
549
550
	if h.fullStacktrace {
551
		return fmt.Sprintf("%+v", trace)
552
	}
553
554
	// Short format: find first non-runtime frame
555
	for _, frame := range trace {
556
		if len(frame.Function) > 8 && frame.Function[:8] == "runtime." {
557
			continue
558
		}
559
		file := frame.File
560
		if idx := lastIndex(file, '/'); idx >= 0 {
561
			file = file[idx+1:]
562
		}
563
		return fmt.Sprintf("%s:%d", file, frame.Line)
564
	}
565
566
	// Fallback to first frame
567
	frame := trace[0]
568
	file := frame.File
569
	if idx := lastIndex(file, '/'); idx >= 0 {
570
		file = file[idx+1:]
571
	}
572
	return fmt.Sprintf("%s:%d", file, frame.Line)
573
}
574
575
func lastIndex(s string, b byte) int {
576
	for i := len(s) - 1; i >= 0; i-- {
577
		if s[i] == b {
578
			return i
579
		}
580
	}
581
	return -1
582
}
583
584
func levelString(level slog.Level) string {
585
	switch {
586
	case level < slog.LevelInfo:
587
		return "DBG"
588
	case level < slog.LevelWarn:
589
		return "INF"
590
	case level < slog.LevelError:
591
		return "WRN"
592
	default:
593
		return "ERR"
594
	}
595
}
596
597
func needsQuoting(s string) bool {
598
	if s == "" {
599
		return true
600
	}
601
	for _, r := range s {
602
		if r <= ' ' || r == '"' || r == '=' || r >= 127 {
603
			return true
604
		}
605
	}
606
	return false
607
}
608

Source Files